Compare commits

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

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

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

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

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

---------

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

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

Fixes #6205

* test(providers): remove trailing blank line

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Part of #4071

* fix(cookbook): bound the OLLAMA_HOST port scan

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

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

Part of #4071
2026-11-06 02:00:44 +00:00
Grzegorz c21980f5a6 fix(auth): revoke API tokens when an admin is demoted (#6831) (#6832) 2026-11-03 05:53:59 +00:00
Grzegorz 7fdc97880a fix(auth): reject usernames with slashes, control characters or over 64 characters (#6820) (#6821) 2026-11-03 05:50:21 +00:00
Grzegorz 9974841c49 fix(auth): validate privilege value types and reject non-object bodies (#6829) (#6830) 2026-11-03 05:48:20 +00:00
GrzegorzandClaude Opus 5.5 631c05603c fix(cookbook): make the Dependencies recipe Run button pass validation (#6725) (#6726)
The recipe Run button sent repo_id "<backend> setup"; the space fails the
pip package-spec check, so every Run returned "Invalid pip package name".
It now sends "<backend>-setup".

The vLLM/SGLang recipes start with `uv pip install ... --torch-backend
auto`, but uv was not in the serve command allowlist. Allow uv only for
the `pip install` subcommand.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:41:21 +00:00
red person f814018098 fix(documents): preserve read pagination within tool output budget (#6810) 2026-11-03 05:37:48 +00:00
Grzegorz d47d85d8a4 fix(history): only accept user/assistant string messages on the session message route (#6826) (#6827) 2026-11-03 05:35:25 +00:00
John WandClaude Opus 5.5 77964b0025 fix(ui): keep the confirm dialog's OK button in line with the others (#6782)
* fix(ui): keep the confirm dialog's OK button in line with the others

#styled-confirm-ok was moved down 2px (position: relative; top: 2px), so
the OK button sat 2px below Cancel and the alternate button in every
styledConfirm dialog. Its label, and the alternate button's, now sit in a
span nudged the same way as the Cancel label, so the text keeps its place
and no button moves. Stylesheets and app.js get a new cache-bust token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): build confirm labels inline and share the Cancel nudge rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 05:29:42 +00:00
red person 682662d36f fix(email): decode MIME alternatives without duplicating bodies (#6953) 2026-11-03 00:39:55 +00:00
red person b7ab0a37c1 fix(modals): keep restored settings and theme windows open (#6924)
* fix(modals): keep restored settings and theme windows open

* fix(theme): cancel closing on a rapid toggle reopen

* test: isolate photo routing from external MCP registry state

* fix(cache): preserve the upstream editor worker key format
2026-11-03 00:37:38 +00:00
red person 3efbecd50a fix(skills): restore the source editor from test results (#6931)
* fix(skills): restore the source editor from test results

* fix(skills): align the chat link module version

* test: isolate photo routing from external MCP registry state
2026-11-03 00:35:37 +00:00
red person 06c1e71fa0 fix(research): typeset math in visual reports with the shared renderer (#6929) 2026-11-03 00:33:43 +00:00
red person 1e34f938cc fix(chat): export the complete conversation across PDF pages (#6921) 2026-11-03 00:30:07 +00:00
John WandClaude Opus 5.5 596e1421e4 fix(ui): let confirm-dialog buttons grow to fit wrapped labels (#6617)
* fix(ui): let confirm-dialog buttons grow to fit wrapped labels

The global `button { height:32px }` rule also applies to styledConfirm's
buttons. A label that wraps to two lines, such as "Delete chat and images",
"Launch anyway (CPU)" or a three-button dialog's alternate action, keeps
one line's height, so its second line renders below the button. On narrow
windows the dialog makes the buttons equal width, so this hits most
multi-word labels; it also happens on desktop, where the dialog box is
narrow too.

Give .styled-confirm-box .confirm-btn height:auto with min-height:32px.
Single-line buttons keep their current 32px.

Bumps the shared stylesheet/app cache token and the service worker cache
name, since a stylesheet changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(static): leave admin.js's cache tag unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:27:29 +00:00
red person cad14f81b9 fix(documents): preserve the selected chat mode on open (#6945)
* fix(documents): preserve the selected chat mode on open

* test: isolate photo routing from external MCP registry state
2026-11-03 00:25:29 +00:00
red person 8532f4606b fix(models): resolve configured defaults after automatic fallback (#6949)
* fix(models): resolve configured defaults after automatic fallback

* test: isolate photo routing from external MCP registry state
2026-11-03 00:23:17 +00:00
red person 5d4e8d57bb fix(memory): rank identity records with other relevance matches (#6925) 2026-11-03 00:21:16 +00:00
red person ed531cfc61 fix(hwfit): complete CPU probe results and clear remote ports (#6920) 2026-11-03 00:19:03 +00:00
Merajul Arefin f00ae350cd fix(cli): multi-word and literal-wildcard search in odysseus-docs (#7007)
* fix(cli): multi-word and literal-wildcard search in odysseus-docs

odysseus-docs search wrapped the whole query in one `%{query}%` LIKE over title/current_content, so multi-word queries only matched the exact adjacent phrase and literal % / _ acted as SQL wildcards. Split on whitespace, AND per token, and escape LIKE wildcards with the shared escape_like_pattern() helper. Whitespace-only queries now match all documents, consistent with document-library search.

Fixes #2123.

Tests: tests/cli/test_docs_cli_search.py (5 tests; multi-word, %, _, backslash, whitespace); 4 fail pre-fix, 5 pass post-fix. tests/cli full suite: 82 passed.

* test(cli): name whitespace-query search test accurately

The test passes a whitespace-only query, not an empty one; the old name implied a different input. Behavior is unchanged.
2026-11-02 23:48:14 +00:00
red person c1f959ae61 fix(research): retain reports after failed deletion (#7043) 2026-11-02 23:45:48 +00:00
Alexandre Teixeira 2790708b19 feat(benchmarks): add offline evidence integrity auditor (#7030)
* feat(benchmarks): add offline C5 evidence auditor

* fix(benchmarks): prevent sensitive data in audit logs

* fix(benchmarks): prevent Markdown report injection

* fix(benchmarks): prevent Markdown inline injection

* fix(benchmarks): secure GFM report rendering

* ci(benchmarks): gate rendered GFM security regressions
2026-11-02 23:07:56 +00:00
4126bea113 fix(gallery): drop the second scrollbar on the photo detail view (#6597)
* fix(gallery): drop the second scrollbar on the photo detail view

With a photo open on desktop, the modal body, the images container and the
detail view are each height: 100%, so the container fills the body exactly.
The container's inline 2px top margin (grid-view spacing) then overflows the
body by 2px, and the body shows its own scrollbar next to the inspector's.
Zero that margin while the detail view is open.

Bumps the shared stylesheet/app cache token and the service worker cache
name, since a stylesheet changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(static): leave admin.js's cache tag unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-02 15:44:29 +00:00
DracieVajkoandMatej f7f6abf82b feat(settings): add a tool approval mode with an explicit off (YOLO) option (#6304)
* feat: add optional tool approval mode

Admin-configurable tool_approval_mode setting (ask/auto) for the
post-external-context tool approval gate.

- ask (default): existing approval flow, gate stays on
- auto: bypasses the approval gate for all runs - same effect as
  ODYSSEUS_TOOL_APPROVAL_GATE=0. Documented honestly in the settings
  row copy and in THREAT_MODEL.md, per review on #6304.
- value validated server-side ({ask, auto}, anything else -> 400)
- no new env var: the setting wires to the existing gate control
- tests: setting validation + _tool_approval_gate_bypassed behavior

* feat: surface deployment gate state on Tool approval row

Follow-up to StressTestor's re-review: when ODYSSEUS_TOOL_APPROVAL_GATE is
set to a falsy value, the Tool approval row still read 'Always ask' even though
the gate was off, misleading admins the other way.

GET /api/auth/settings now returns tool_approval_gate_env_disabled (derived
from TOOL_APPROVAL_GATE_ENABLED) for admins, and the settings row renders a
red notice naming the env var and explaining that the gate is off for every
run regardless of the stored setting. Non-admin/scrubbed responses do not
expose the deployment state. Test covers both env values and the scrub path.

* refactor: rename tool approval mode auto to off

The gate-off value is now "off" ("Off (YOLO)" in the UI) so the auto-approve name stays available for the future judged mode.

---------

Co-authored-by: Matej <matej@local>
2026-11-01 12:22:56 -07:00
Alexandre Teixeira 2a413814c5 fix(chat): honor effective RAG policy in context building (#7025) 2026-11-01 16:14:51 +00:00
Tarık KöşkerandAlexandre Teixeira 1de3c72140 fix(effects): open the effect journal in binary mode on Windows (#6811)
Raw os.open() is text-mode on Windows unless O_BINARY is set, so each
\n written by the journal became \r\n. The log tracks byte offsets,
so the next append read the extra byte as a corrupt tail and every tool
call after the first in a turn failed with 'Effect claim could not be
persisted durably'.

OR O_BINARY (0 on POSIX) into the journal and launch-reservation opens.

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 16:12:42 +00:00
Yu-Wei ChangandAlexandre Teixeira 8450793fe6 feat(admin): rename model endpoints inline (#6970)
* feat(admin): rename model endpoints inline

* fix(cookbook): keep renamed endpoint names on relaunch

Relaunching a model at an existing endpoint URL reset the endpoint name
to the launched repo's short name, undoing a rename made in Added Models.
Only replace the name when it is still a name the Cookbook generates for
one of the endpoint's pinned models, so endpoints that were never renamed
still follow the newly served model.

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 08:45:11 +00:00
c4fdd7ad5b tests: skip the searxng settings migration tests on Windows (#6882)
scripts/migrate_searxng_settings.py runs inside the searxng container
entrypoint and preserves the settings file's owner and mode with
os.fchmod/os.fchown, which do not exist on Windows, so every subprocess
run of the script in this module fails with AttributeError before the
behaviour under test is reached. Mark the module POSIX-only.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 08:33:08 +00:00
JulianFriedleandAlexandre Teixeira 6fced41220 feat(mcp): let a deployment declare local MCP tools (#6330)
* feat(mcp): let a deployment declare local MCP tools

Tools served by a user-configured MCP server are absent from
TOOL_CAPABILITIES, so they are unknown and fail high. For a server with
both a read and a write tool that is unusable: the read result arms the
post-external gate, because an unknown result is not SYSTEM integrity,
and the write that follows is refused until separately authorized. Every
second call needs an approval, for a server that never leaves the host.

Add ODYSSEUS_LOCAL_MCP_READ_TOOLS and ODYSSEUS_LOCAL_MCP_WRITE_TOOLS.
Declared read tools become READ_PRIVATE with SYSTEM integrity, so their
output no longer arms the gate; declared write tools become WRITE_PRIVATE
and stay inside POST_EXTERNAL_BLOCKED_EFFECTS, so a run tainted by real
external content still requires the approval. Unset variables keep the
current behaviour exactly.

Fixes #6329

* fix(mcp): secure local MCP authorization and preserve external taint gate

Keep declared local MCP read tool results untrusted with EXTERNAL_UNTRUSTED
integrity so they continue to arm the external-context gate. Introduce a
narrowly scoped continuation exception for declared local MCP writes on
the same server when no external taint or explicit error payloads enter
the run. Preserves gate protection for shell execution, network egress,
email, and cross-server actions.

Fixes #6329

* fix(mcp): resolve local continuation against stdio transport and enforce built-in classifications

Resolve declared local MCP continuation operations against connected stdio
servers and enforce established built-in tool security classifications.
Ensure remote HTTP and SSE servers, unverified transports, replaced sessions,
stale configurations, and operations with dangerous effects or missing request
grants fail closed.

* fix(mcp): require sealed authority for local continuation

* fix(mcp): seal local tool admission and synchronize GPU stacks

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 03:19:59 +00:00
Yu-Wei ChangandAlexandre Teixeira 6f2b58c896 fix(cookbook): preserve endpoints across model launches (#6969)
* fix(cookbook): match stale endpoints by launched model, not display name

After registering an LLM endpoint, the Cookbook deleted every other local-*
endpoint with the same display name at a different URL. The display name is
the repo short name, which repos from different orgs share (for example
meta-llama/Llama-3.1-8B-Instruct and unsloth/Llama-3.1-8B-Instruct), so
launching one deleted the endpoint of the other while its server was running.

Match stale endpoints by the full model id they were last launched for,
which is the last entry of their pinned_models. Reusing an endpoint now
moves the launched model to the end of its pins so that entry stays current.

* fix(cookbook): guard endpoint cleanup by serve ownership

Preserve independently launched models instead of sweeping endpoints by display name or mutable model pins.

Bind Cookbook endpoint registrations to their serve sessions and prevent stale watchdogs or scheduled stops from deleting newer registrations.

Reuse the existing endpoint deletion contract to preserve settings, preferences, session cleanup, and cache invalidation.

Cover LLM and image endpoint lifecycle regressions.

---------

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 03:06:13 +00:00
39303f19cd fix(editor): stabilize image generation, layer editing and controls (#6992)
* fix(editor): stabilize image workflows and editing controls

* docs(editor): add desktop and mobile visual validation evidence

* fix(editor): synchronize quick edit model events and register imagePreferences in test coordinator

* fix(settings): enforce authoritative global image-gen policy and non-admin preference persistence

- Enforce authoritative administrator global image_gen_enabled disable in get_user_setting, do_generate_image, and image_editing_provider_available, preventing non-admin user preference overrides while preserving user-disabled opt-outs and can_generate_images privilege checks across direct, gallery, and editor paths.
- Filter non-admin preference persistence in saveImagePreferences to user preference keys only (image_model, image_edit_model, image_quality), preventing global policy toggles from leaking into user preferences.
- Return accurate 200 OK Response from saveImagePreferences after fallback persistence succeeds so settings UI displays 'Saved' instead of false 403 error, while retaining visibility of real errors.
- Load per-user preferences via readImagePreferences in initImageSettings so non-admin selections persist and display correctly after page reload.
- Clear per-user model preferences on successful admin save (POST /api/auth/settings) via PUT /api/prefs/{key} with value: null to prevent stale per-user preferences from shadowing updated global settings.
- Support clearing keys in PUT /api/prefs/{key} when value is null.
- Add comprehensive Python regression tests for global-disabled/user-enabled, global-enabled/user-disabled, and authorized generation, plus Node and browser regression tests for non-admin persistence, toggle isolation, reload, and admin clearing.

* fix(settings): invalidate shared appConfig cache near auth settings write

* fix(test): isolate test_tool_policy unit suite from external model endpoint state

* fix(test): isolate web_fetch failure regression from contextual follow-up heuristics

* fix(test): isolate test_tool_policy from cross-test approval gate state

Ensure _patch_loop_basics disables TOOL_APPROVAL_GATE_ENABLED so routing tests in test_tool_policy run in the intended opt-out posture even when prior tests populate DATA_DIR with skills or untrusted context. Remove unnecessary forced_tools and contextual web mock workarounds.

* fix(settings): isolate non-admin image toggle and surface preference cleanup errors

- Restrict image generation availability toggle to admin users in imageModels.js, disabling the switch for non-admins and reverting unauthorized changes with an explicit message.
- Separate non-admin image model preference saves from global toggle updates.
- Track preference cleanup results on admin save in imagePreferences.js, propagating cleanup failures instead of ignoring them.
- Require valid preference keys on 403 fallback so toggle-only attempts reject cleanly.
- Add /api/prefs route mock to test_image_model_settings_sync_both_directions_and_survive_reopening.
- Add regression coverage for admin cleanup failures and non-admin toggle rejection.

* fix(settings): reconcile admin toggle on partial cleanup failure and report errors

- In imagePreferences.js, distinguish pre-persistence failures from post-save cleanup failures using err.globalSaved and err.phase.

- In imagePreferences.js, notify listeners of global policy updates on partial success before propagating cleanup failure.

- In imageModels.js, retain the persisted target state on admin toggle when global persistence succeeds but cleanup fails.

- In imageModels.js, reconcile admin toggle with authoritative server state via readImagePreferences() after ambiguous or partial failures.

- In imageModels.js, report partial success accurately without suppressing preference cleanup error messages.

- Add regression coverage in test_settings_save_response_ok_js.py for cleanup HTTP 500, cleanup network rejection, successful cleanup, and failed global POST.

---------

Co-authored-by: pewdiepie-archdaemon <pewdiepie-archdaemon@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:56:46 +00:00
5353241743 fix(chat): warn before regenerate, resend or edit deletes later messages; offer fork (#6225)
* fix(chat): warn before "regenerate" permanently deletes later messages

Both "regenerate" entry points — the per-message footer's ↻ "Regenerate
from here" button, and the vision editor's "Regenerate message" button —
call POST /api/session/{id}/truncate, which does a real, permanent SQL
delete of every message after the point clicked, then generates a new
reply. Clicking either on anything but the very last exchange did this with
zero warning: the button's own label doesn't say it also erases the rest
of the conversation, and neither button visually signals it's destructive
differently from any other message-footer action.

Caught live: a mis-click destroyed a real conversation with no way to
recover it (no backup covered the affected window). This adds a
confirmation prompt — via the existing uiModule.styledConfirm — whenever
the action would delete more than just the message(s) being regenerated,
stating the exact count of messages that would be lost. Regenerating the
most recent exchange (the everyday case, nothing exists after it) is
unaffected and still proceeds with zero friction.

New test: tests/test_regenerate_truncate_warning_js.py — drives the real
regenerateFrom/resendUserMessage functions under Node with a fake message
list DOM, confirming: the last-exchange case never prompts and truncates
directly; an earlier case prompts with the correct count; cancelling
aborts before any network call; confirming proceeds with the correct
keep_count. Also fixed a pre-existing, unrelated gap this surfaced while
verifying the full suite: test_pr6020_browser_review_regressions.py's
Node harness never stubbed `navigator` at all (sessions.js reads
navigator.platform at module load) — a gap on origin/dev itself, not
caused by this branch's diff, using the same Object.defineProperty fix
already applied on #6019/#6148 for the sibling issue there (Node 21+'s own
getter-only navigator global, not relevant to this specific failure but
the same defensive pattern).

Verified: full pytest suite in-container, 5798 passed / 11 pre-existing
unrelated failures (README/docs/agent-loop baseline) / 0 new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(chat): cover plain resend after dev's replace-by-default change

dev made plain resend (no options) replace from the clicked turn by
default, with append-only resend opt-in via { append: true } (see
test_resend_message_nondestructive.py). The footer "Resend message"
button calls resendUserMessage(el) with no options, so it now truncates
everything after the clicked turn and goes through this PR's warning.
Replace the old "plain resend never prompts" test, which assumed the
previous append-by-default behavior, with one asserting that plain
resend on an earlier pair prompts with the right count, and one
asserting that explicit append-only resend never truncates or prompts.

Also resolve ui.js/sessions.js through the same cache-busting query
string chat.js imports them with. ES modules cache by full URL, so the
harness's unversioned import of ui.js was a second module instance and
its uiMod.styledConfirm stub never reached the instance chat.js calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): warn before an edit deletes later messages; offer to fork instead

Sending an edited user message truncates the chat at that message, the
same unrecoverable server-side delete as regenerate and resend, but did
it with no prompt. It now goes through the same confirmation, worded for
the action ("Sending this edit will also permanently delete the N
messages after it"). Cancelling leaves the editor open with the draft.

The confirmation for all three actions (regenerate, resend, edit) also
offers "Fork from here": it forks the chat just before the user turn,
switches to the new chat, and replays the action there (the same message,
or the edited text), leaving the original chat untouched. forkFrom now
shares the same fork helper.

Tests drive the real editUserMessage Send handler under Node and cover
the edit prompt (count, cancel, last-message no-prompt) and the fork
choice for each action (fork keep_count, no truncate, replayed text).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): cut and fork a chat by message id, not loaded-page position

Regenerate and edit sent keep_count, the clicked message's position among
the messages loaded in the page. A long chat loads only its newest messages
until the user scrolls up, so that position is smaller than the real one and
the truncate deleted real messages above the clicked one (regenerating the
latest reply in a ~50-message chat removed about a dozen earlier messages).
Resend already sent before_msg_id; regenerate and edit now do the same.

The fork options this PR adds had the same flaw. Fork now accepts
before_msg_id (fork before that message) and through_msg_id (include it),
resolved against the stored messages; keep_count stays as the fallback for a
message that has no id yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): keep a regenerated turn's message id current; stop on a refused cut

Regenerate keeps the original user bubble on screen and resubmits its text
without drawing a new bubble. The resubmitted message is a new DB row, but
the bubble kept the deleted row's id, so the next regenerate's cut was
refused (404) and the page sent the turn anyway, stacking a second reply.

The stream now reports the saved user message's id (user_message_saved),
which the page attaches to the user bubble, or to the bubble a regenerate
kept. Regenerate and edit stop with an error when the server refuses the
cut instead of sending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chat): name the buttons in words instead of symbols

CONTRIBUTING asks for no Unicode emoji in UI or code; the test comments
used the regenerate and pencil glyphs to name buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(chat): share the fork-and-send step across edit, resend and regenerate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): pass the regenerate fork its attachment ids explicitly

Resend already hands its collected file ids to _forkAndSend; regenerate
relied on the earlier _pendingRegenAttachments assignment. Pass _regenIds
too so both fork paths read the same, and check the hand-off in the
regenerate tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(chat): assert fork choices keep the user message's attachments

Run regenerate and resend's "Fork from here" against a user message with
an uploaded file and check that the send fired in the fork carries its
file id, replacing the source-text check. chat.js exposes a read-only
_pendingRegenAttachmentIds() for this, like cookbook-hwfit.js's test hooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(chat): preserve attachments across edit and regeneration

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:47:32 +00:00
Wojciech TaisnerandAlexandre Teixeira 285d05e72e fix(docker): forward ODYSSEUS_FIRST_TOKEN_TIMEOUT through compose (#6778)
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:28:58 +00:00
dc0fbbf7aa fix(compose): default ODYSSEUS_TTS_CACHE_MAX_BYTES so compose stops warning (#6619) (#6645)
The three shipped compose files forwarded the variable bare, so every
`docker compose` command on a default .env printed "variable is not set".
Default it to the 500 MiB that .env.example documents, like the other
limits, and add a guard test that rejects any bare ${VAR} in compose files.

Fixes #6619

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 02:03:06 +00:00
zai184andAlexandre Teixeira 12b9c5f2ad chore(compose): parameterize searxng host port via SEARXNG_HOST_PORT (#6598)
The searxng host binding was hardcoded to 127.0.0.1:8080 in all three
compose files, which collides with other stacks that legitimately use
8080 (e.g. a local llama.cpp server). Parameterize it the same way the
odysseus UI port already works:

  ports:
    - "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"

Default unchanged (8080). The UI->searxng connection uses the container
port over the docker network (SEARXNG_INSTANCE=http://searxng:8080), so
no other change is needed.

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:55:36 +00:00
cb26ccd9a7 test: skip native bash tool tests on Windows (2 modules) (#6872)
* tests: skip native bash behavior test on Windows in test_production_external_bridge

On Windows the only containment mechanism (windows_tree) enforces wall-clock
only; ENFORCING mode refuses bash (filesystem/process_tree unavailable), so
exit_code is 1. The test asserts bubblewrap/process_group: POSIX-only by design.

Part of #4287

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* tests: skip native bash exit-status test on Windows in test_preview_execution_evidence

BashTool needs enforcing containment (filesystem/process_tree); Windows
windows_tree gives wall-clock only, so the command is not executed and
exit_code is 1 instead of 7. POSIX-only by design.

Part of #4287

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:47:57 +00:00
23a480571a tests: skip the 0o600 file-mode assert on Windows in the effect log fsync test (#6892)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:39:56 +00:00
6a43cf23ea tests: make hardcoded /tmp assertions OS-neutral (#6864)
Root cause: _is_sensitive_path splits on os.sep, so POSIX literals missed
on Windows; /tmp is not a root on Windows (only $TMPDIR); inline-JSON test
compared against "/tmp/wf.txt" while Windows abspath is C:\tmp\wf.txt.
Change: native-separator helper, TMPDIR monkeypatch, abspath alternative.
Before: 9 failed; after: 0 failed (74 passed, 1 symlink skip).

Part of #4287

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:33:28 +00:00
64b457dc0a fix(tests): read static sources as UTF-8 in the static assertion tests (#6850)
On native Windows the default text encoding is the ANSI code page
(cp1252 on en/pt systems), so the module-level read_text() calls in
eight static tests fail at collection with UnicodeDecodeError on bytes
0x81/0x90 that appear in static/js/chat.js, chatRenderer.js, app.js,
index.html and src/visual_report.py. Pass encoding="utf-8" like the
other 700+ tests in the suite already do.

Found by running the suite natively on Windows 11 (Python 3.11).

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 01:26:11 +00:00
red person ef3467e8d4 fix(tasks): render daily brief calendars in local time (#6926) 2026-10-31 13:54:14 +00:00
red person 72e8520fa3 fix(models): honor visible pinned IDs in route selection (#6922) 2026-10-31 13:51:59 +00:00
red person 9276db9c11 fix(models): skip non-chat models when resolving chat defaults (#6907) 2026-10-31 13:49:26 +00:00
GrzegorzandClaude Opus 5.5 a797cee0b3 fix(cookbook): diagnose vLLM serves on an NVIDIA driver that is too old (#6727) (#6728)
- Add a diagnosis for "NVIDIA driver on your system is too old" and
  "CUDA driver version is insufficient for CUDA runtime version", the
  errors PyPI's CUDA 13 torch/vLLM builds hit on drivers older than 580.
- Remove the nested _diagnose_serve_output copy in cookbook_routes.py.
  It shadowed the helper and had drifted, so /api/cookbook/tasks/status
  never used the helper's newer diagnoses.
- Capture the tmux pane with -J in the status poll so lines wrapped at
  the pane width do not split error text mid-word.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 13:47:30 +00:00
Grzegorz 76061498c8 fix(gallery): validate album rename and image_ids input (#6822) (#6823) 2026-10-31 13:45:02 +00:00
Merajul Arefin 04f8546d9d fix(search): gallery library per-word search with LIKE escaping (#6584)
Gallery search built a single `%{search}%` LIKE, so multi-word queries only matched the exact adjacent phrase ("red car" missed "a car that is red") and literal % / _ acted as SQL wildcards. Split on whitespace and require each token to match (prompt OR tags OR ai_tags), escaping LIKE wildcards per token; same escaping for the tag filter. Promote _escape_like to public escape_like_pattern() shared helper.

Part of #2123.

Tests: tests/test_gallery_search_multiword.py (5 tests); 4 fail pre-fix, 5 pass post-fix. Neighbors (session_search, gallery null-user, shims): 25 passed.
2026-10-31 13:43:20 +00:00
Robert Vind-Gardoș 9835c1a7f9 fix(skills): refuse renaming a skill onto a name the owner already uses (#6941)
update_skill only checked whether the target directory existed. A skill
with that name in another category lives elsewhere on disk, so the
rename went through and left two skills with one name. Update and
delete look skills up by name, so the next delete removed whichever
file os.walk reached first.

Check the owner's skills in every category before renaming, and answer
409 from PUT /api/skills/{id} instead of the misleading 404.
2026-10-31 13:34:05 +00:00
Isharakandisharak7m 239f09cbc4 fix(docker): retry PyPI fetches in realesrgan wheel builder (#6407)
Replace urllib-based PyPI metadata retrieval with curl --retry 8 to
handle transient TLS/SSL EOF errors that abort the Docker image build.
The sdist download also gets retry logic. No functional change to the
patching or wheel-building steps.

Co-authored-by: isharak7m <192635824+isharak7m@users.noreply.github.com>
2026-10-31 13:32:28 +00:00
GrzegorzandClaude Opus 5.5 a1a7f8fc9a fix(agent): don't clamp an unknown context window to the small-server budget (#6678) (#6706)
budget_context_for_model() returns 0 when the endpoint does not prove its
context window, and compute_input_token_budget() answers 0 with the
conservative default budget (6000). The agent loop's small-local-server
clamp (`candidate_context <= 8192`) also matched 0 and cut the request to
max(1200, 0) tokens, so on any endpoint with an unreported window an open
document or a few thousand tokens of earlier context were trimmed away
before the model saw them ("5519 tokens > 1200 budget (ctx=1800)").

Apply the clamp only to a known window.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 13:30:29 +00:00
Prince 87193d23f5 fix(agent-loop): release buffered prose when '<' is not a tool tag (#6757) (#6758) 2026-10-31 13:26:43 +00:00
VykosandClaude 516f046fef fix(chat): label the vision description injected for text-only models (#6223)
When the chat model cannot see images, the separate vision call's description
is appended to the user's message. Nothing marked it as machine-derived, so the
chat model read it as the user's own prose and answered the description instead
of the question.

The label goes below the `[Image: <name>]` marker line because the client
strips the block by matching that exact prefix; renaming the marker would leave
the raw description rendered in the user's bubble.

Fixes #5573

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-31 13:24:32 +00:00
red person 48c3a7fc36 fix(pdf): convert CMYK images before vision analysis (#6917) 2026-10-31 13:22:55 +00:00
red person 70fc58225c fix(documents): treat library search punctuation literally (#6944) 2026-10-31 13:21:10 +00:00
red person 9efedc8b07 fix(documents): retain language extensions in ZIP exports (#6848) 2026-10-31 13:18:58 +00:00
gloss-stack 1d251ef867 Fix transcript search snippet positioning (#6206) 2026-10-31 13:05:35 +00:00
Nguyen Thanh Dat bf5bf57bc1 fix(diagnostics): report a failed config source instead of "disabled" (#6154) (#6169)
`_gather_inputs()` fails soft: a source that raises leaves its inventory as
`[]`. Every probe reads an empty inventory as "not configured" and answers
`disabled`, so a broken integrations file, email store, or endpoint database is
indistinguishable from a subsystem the operator deliberately turned off. With
the remaining subsystems disabled, `GET /api/diagnostics/services` then reports
`overall: "ok"` while three of its inputs failed to load — the exceptions appear
only in DEBUG logs.

Record each failure alongside the empty value (`failed`: source -> controlled
category) and report the affected subsystem through `_source_unavailable()`
rather than probing it with an inventory that is empty for the wrong reason.

- `degraded`, not `disabled`: the configured state is unknown, not known-off.
  Not `down` either — nothing reached the subsystem and failed; the inventory
  could not be read. Severity is non-zero either way, so a report containing a
  failed source can no longer roll up to `ok`.
- A source that loads and is genuinely empty still reports `disabled`.
- One broken source degrades one entry; the others are still probed.
- `meta` carries the source name and the controlled `_classify_error()`
  category only, never exception text, credentials, or database detail.
- `inputs.get("failed")` so an inputs payload without the key still probes
  normally.

Verified with the reporter's harness: settings loading with
`search_provider: "disabled"` while the other three sources raise now yields
`overall: "degraded"` with `searxng` still `disabled`; before, all five read
`disabled` and `overall` was `ok`.
2026-10-31 13:04:18 +00:00
MBGAandBolt4243 2019a97339 fix(skills): preserve escaped quotes in frontmatter lists (#6265)
* fix(skills): preserve escaped quotes in frontmatter lists

* test(skills): use descriptive frontmatter examples

---------

Co-authored-by: Bolt4243 <277300782+Bolt4243@users.noreply.github.com>
2026-10-31 13:02:02 +00:00
Aashish Mahato 9a1fdb192e fix(cookbook): resolve NameError in hf-gguf-files exception handler (#6341) (#6369)
In routes/cookbook_routes.py, the exception handler for GET /api/cookbook/hf-gguf-files referenced repo instead of the parameter repo_id, causing an unhandled NameError and 500 status when upstream requests to Hugging Face failed.

Fixes #6341.
2026-10-31 12:59:17 +00:00
nopoz bebf53be3c fix(agent): make preview and browser output parsers linear (#7003)
Six regexes over tool output backtracked polynomially on long whitespace
runs or repeated openers: the browser snapshot generic-line filter and
post-interaction split, the task-row and skill-bullet parsers, the email
"no results" check, and the Google /sorry/ interstitial check.

Rewrite each without overlapping quantifiers, keeping the same matches for
the text these tools emit. Move the /sorry/ check into
is_google_sorry_url() so it can be tested.
2026-10-30 22:46:19 -07:00
Alexandre Teixeira a8e0ff1c8e Merge pull request #6998 from odysseus-dev/maintainer/fix-6546-scientific-data
fix(llm): support scientific and decimal literals in degenerate guard
2026-10-31 02:43:09 +00:00
Alexandre Teixeira 31685b050a fix(llm): support scientific and decimal literals in degenerate guard
Preserve complete scientific notation and signed floating-point literals across
arbitrary streaming chunk boundaries in _DegenerateStreamGuard. Buffer pending
tokens until delimiter characters arrive to avoid splitting numbers into
incomplete or arbitrary alphanumeric tokens, while maintaining the 2048-number
runaway backstop and detection of repeated phrases and word loops.
2026-10-30 20:45:09 +00:00
Alexandre Teixeira 03d25943ee Merge pull request #6993 from odysseus-dev/perf/editor-cursor-style-invalidation
perf(editor): avoid brush cursor style invalidation
2026-10-30 20:09:16 +00:00
Alexandre Teixeira 71e3649985 Merge pull request #6947 from redpersongpt/codex/compare-stream-contract
fix(compare): reject incomplete and errored SSE responses
2026-10-30 19:47:28 +00:00
Alexandre Teixeira 45a57c0c38 Merge pull request #6911 from redpersongpt/codex/nested-window-close
fix(ui): return to the parent view when closing an item
2026-10-30 19:47:11 +00:00
Alexandre Teixeira 71f6dc4e5d Merge pull request #6912 from redpersongpt/codex/email-linkify-single-pass
fix(email): linkify URLs and addresses without rewriting anchors
2026-10-30 19:46:54 +00:00
Alexandre Teixeira 0c8a06a937 Merge pull request #6943 from redpersongpt/codex/chat-edit-textarea-content
fix(chat): size user-message editors from wrapped content
2026-10-30 19:46:37 +00:00
Alexandre Teixeira eea39ce103 Merge pull request #6934 from redpersongpt/codex/skill-editor-load-guard
fix(skills): wait for source before enabling the editor
2026-10-30 19:46:21 +00:00
Alexandre Teixeira 9a5a15e17f Merge pull request #6932 from redpersongpt/codex/skill-editor-source-height
fix(skills): size the raw editor from its source lines
2026-10-30 19:46:04 +00:00
Alexandre Teixeira f141ad2be5 Merge pull request #6919 from redpersongpt/codex/memory-panel-refresh
fix(memory): refresh records when opening the panel
2026-10-30 19:45:47 +00:00
Alexandre Teixeira a6e9294394 Merge pull request #6918 from redpersongpt/codex/memory-stale-category
fix(memory): reset category filter when its last entry disappears
2026-10-30 19:45:30 +00:00
Alexandre Teixeira cfbe4aeca4 Merge pull request #6915 from redpersongpt/codex/image-quality-explicit-default
fix(images): preserve explicit medium quality requests
2026-10-30 19:45:13 +00:00
Alexandre Teixeira 6f0679bb25 Merge pull request #6253 from VykosMolt/fix/generate-image-native-schema-5520
fix(tools): reject generate_image calls without a prompt
2026-10-30 19:44:56 +00:00
Alexandre Teixeira 93c93bf076 Merge pull request #6748 from dxrpl/fix/6747-recipe-panel-toggle
fix(cookbook): bring back the Dependencies recipe-panel caret (#6747)
2026-10-30 19:44:39 +00:00
Alexandre Teixeira f0f7464dfa Merge pull request #6952 from redpersongpt/codex/mail-cli-date-order
fix(mail): sort CLI message batches by UTC date
2026-10-30 19:44:23 +00:00
Alexandre Teixeira 1e57f774d3 Merge pull request #6913 from redpersongpt/codex/font-compound-style-family
fix(fonts): group compound weight and style variants by family
2026-10-30 19:44:07 +00:00
Alexandre Teixeira 2b310010cf Merge pull request #6930 from redpersongpt/codex/stale-hf-cache-markers
fix(cookbook): ignore empty markers beside completed blobs
2026-10-30 19:43:50 +00:00
Alexandre Teixeira b88154ecf9 Merge pull request #6730 from dxrpl/fix/hwfit-hf-param-count
fix(hwfit): keep the MoE active size for HF collection models (#6729)
2026-10-30 19:43:34 +00:00
Alexandre Teixeira e7796f71d3 Merge pull request #6890 from redpersongpt/codex/checkin-calendar-window-boundaries
fix(checkin): avoid duplicate calendar events at window boundaries
2026-10-30 19:43:18 +00:00
Alexandre Teixeira 412789051e Merge pull request #6585 from robyroro/codex/notes-clear-fields
fix(notes): persist removal of optional note fields
2026-10-30 19:43:01 +00:00
Alexandre Teixeira b8adc4eb60 Merge pull request #6904 from dxrpl/fix/calendar-config-object-body
fix(calendar): answer 400 when a CalDAV config body is not a JSON object (#6903)
2026-10-30 19:42:45 +00:00
Alexandre Teixeira 2246215b79 Merge pull request #6906 from dxrpl/fix/more-validation
fix(api): answer 400 for a malformed body on integrations and document export-zip (#6905)
2026-10-30 19:42:28 +00:00
Alexandre Teixeira 06fee0a3d8 Merge pull request #6546 from harshit-ojha0324/fix/degenerate-guard-numeric-data
fix(llm): stop the repeat guard from cutting off numeric data
2026-10-30 19:41:03 +00:00
Alexandre Teixeira f3813c261c perf(editor): avoid brush cursor style invalidation 2026-10-30 19:27:58 +00:00
Alexandre Teixeira e7a376b161 Merge pull request #6950 from redpersongpt/codex/group-peer-history-identity
fix(group): preserve peer identity when reloading history
2026-10-30 19:03:48 +00:00
Alexandre Teixeira fec2bb11d6 Merge pull request #6951 from redpersongpt/codex/gallery-cli-tag-filters
fix(gallery): match stacked CLI filters against AI tags
2026-10-30 19:03:01 +00:00
Alexandre Teixeira c811f76d44 Merge pull request #6935 from redpersongpt/codex/email-paperclip-files
fix(email): retain files in attachment and inline-image pickers
2026-10-30 19:02:22 +00:00
Alexandre Teixeira 02d28c06d0 Merge pull request #6888 from redpersongpt/codex/skill-version-preservation
fix(skills): preserve version strings across markdown saves
2026-10-30 19:01:47 +00:00
Alexandre Teixeira 00ecf1c74e Merge pull request #6819 from dxrpl/fix/6818-image-routes-bad-input
fix(gallery): answer 400 for a missing or invalid image on the image-edit routes (#6818)
2026-10-30 19:01:11 +00:00
Alexandre Teixeira 63feb227bb Merge pull request #6732 from dxrpl/fix/hwfit-skip-non-llm-collection-models
fix(hwfit): keep non-LLM HF collection models out of the LLM catalog (#6731)
2026-10-30 19:00:33 +00:00
Alexandre Teixeira 12fcddefab Merge pull request #6891 from redpersongpt/codex/pdf-form-unicode-field-names
fix(forms): preserve Unicode field names during PDF export
2026-10-30 18:59:47 +00:00
Alexandre Teixeira b46aa02ce1 Merge pull request #6954 from redpersongpt/codex/discovery-valid-model-ids
fix(models): retain valid discovery IDs beside malformed rows
2026-10-30 18:58:55 +00:00
Alexandre Teixeira 352ee706f3 Merge pull request #6825 from dxrpl/fix/6824-cron-five-fields
fix(tasks): refuse cron expressions with a seconds field (#6824)
2026-10-30 18:58:03 +00:00
Alexandre Teixeira 98b359cd4c Merge pull request #6982 from odysseus-dev/test/remediation-notes-process-cleanup
perf(tests): close owned Notes descendants before draining output
2026-10-30 18:53:34 +00:00
Alexandre Teixeira 2a2256af0e Merge pull request #6979 from odysseus-dev/test/remediation-static-contract-sensitivity
test: restore shadowed editor assertions and require effective procfs guards
2026-10-30 18:52:46 +00:00
Alexandre Teixeira 4f131d7c21 Merge branch 'dev' into test/remediation-notes-process-cleanup 2026-10-30 18:33:52 +00:00
Alexandre Teixeira 945e747eb4 Merge branch 'dev' into test/remediation-static-contract-sensitivity 2026-10-30 18:33:48 +00:00
Alexandre Teixeira 61051d28f0 Merge pull request #6996 from odysseus-dev/test/ci-python-baseline-repair
test(caldav): restore redirect positive-control coverage
2026-10-30 15:53:51 +00:00
PR #6029 Reviewer d87ae2a7f5 test(caldav): restore redirect positive-control coverage 2026-10-30 15:35:37 +00:00
Alexandre Teixeira 0e49c4ef40 Merge pull request #6978 from odysseus-dev/test/remediation-false-green-oracles
test: reject vacuous success in email, bridge, auth and deletion tests
2026-10-30 15:25:10 +00:00
Alexandre Teixeira 1aff71836b Merge pull request #6611 from robyroro/fix/chat-tidy-preserve-content
fix(chats): preserve saved conversations during tidy
2026-10-30 15:09:09 +00:00
Alexandre Teixeira b1c3125865 test: accept positional arguments in task endpoint mock for auto-sort 2026-10-30 15:00:08 +00:00
Alexandre Teixeira dbee1155e6 Merge pull request #6080 from alteixeira20/fix/cookbook-stale-llama-server-shim
fix(cookbook): recover from stale llama-server shims
2026-10-30 14:13:15 +00:00
Alexandre Teixeira 56c7cdf804 Merge pull request #6544 from harshit-ojha0324/fix/mcp-http-connect-leak
fix(mcp): close the HTTP transport when a remote connect fails
2026-10-30 14:09:43 +00:00
Alexandre Teixeira fe5d5caf34 Merge pull request #6977 from odysseus-dev/test/remediation-execution-isolation
test: fix pytest schema loss across route-client threads
2026-10-30 13:59:20 +00:00
Alexandre Teixeira bb654482f3 Merge pull request #6774 from odysseus-dev/fix/ios-safe-area-postmerge-hardening
fix(ui): harden iOS viewport and safe-area behavior after #6396
2026-10-30 13:56:25 +00:00
Alexandre Teixeira f45724faed Merge pull request #6983 from odysseus-dev/test/remediation-auth-fixture-seed
perf(tests): reuse immutable auth setup with fresh per-case state
2026-10-30 13:42:10 +00:00
Alexandre Teixeira 6fc6095aec test(e2e): harden mobile layout coverage, artifact isolation, and live pane assertions
- Store Playwright evidence in ignored test-results/ paths by default while preserving env override
- Clean up disposable temporary application data directories in /tmp on global teardown and exit signals
- Add Chromium mobile and tablet viewport profiles (phone portrait/landscape, tablet) for automated coverage
- Add a scoped Chromium mobile layout CI check on shard 1/4 to prevent redundant matrix execution
- Reach mobile Settings and Notes views via genuine sidebar navigation controls opened through the hamburger button
- Add assertions against live rendered Notes and Documents panes honoring safe-area top and height constraints
- Document local WebKit prerequisites and physical iOS/PWA test limitations in tests/e2e/README.md and package.json
2026-10-30 13:41:15 +00:00
Alexandre Teixeira ef16398c5c merge(dev): integrate latest dev into fix/ios-safe-area-postmerge-hardening 2026-10-30 13:25:33 +00:00
Alexandre Teixeira 6037707430 fix(cookbook): launch validated llama-server candidate and handle PATH precedence
Ensure the native candidate that passed the health check is prioritized and executed when an earlier broken candidate exists on PATH.

Reconcile Odysseus-owned shims safely, invalidate command hash cache, and verify launchability rather than command existence before serve dispatch.

Fixes #6079
2026-10-30 13:15:40 +00:00
Alexandre Teixeira 5c542d03ac Merge remote-tracking branch 'origin/dev' into fix/pr-6080-llama-recovery 2026-10-30 13:02:47 +00:00
red person 4fe8fb28e5 test: isolate photo routing from external MCP registry state 2026-10-29 23:45:05 +03:00
red person 84e232e7ee fix(cache): retain the upstream worker key format 2026-10-29 23:42:25 +03:00
red person 2cace27723 test: isolate photo routing from external MCP registry state 2026-10-29 23:40:25 +03:00
red person 508c7b3d9d Merge current dev into PR 6932 2026-10-29 23:28:45 +03:00
red person 5b8a72931d Merge current dev into PR 6934 2026-10-29 23:28:28 +03:00
red person 2e7edf908d Merge current dev into PR 6935 2026-10-29 23:27:57 +03:00
red person b80f9afb3e Merge current dev into PR 6943 2026-10-29 23:27:34 +03:00
red person 533d48ad8d Merge current dev into Compare SSE fix 2026-10-29 23:26:15 +03:00
red person d5bc7fb70e Merge current dev into group peer history fix 2026-10-29 23:21:20 +03:00
Alexandre Teixeira 3b8c60ac1e test: check database ownership against the private temp namespace 2026-10-29 17:49:45 +00:00
Alexandre Teixeira d237c3fbba perf(tests): reuse immutable admin auth configuration 2026-10-29 17:35:59 +00:00
Alexandre Teixeira f510b1973a perf(tests): bound Notes server descendant cleanup 2026-10-29 17:35:52 +00:00
Alexandre Teixeira fe81c491cd test: give collection database URL one cleanup owner 2026-10-29 17:30:10 +00:00
Alexandre Teixeira 1339efa856 test: restore editor aliases and validate procfs guard control flow 2026-10-29 17:12:26 +00:00
Alexandre Teixeira 6f5c28b345 test: require real cold reads, cancellation and auth writes 2026-10-29 17:12:20 +00:00
Alexandre Teixeira aa2db8d87c test: keep worker collection databases consistent across threads 2026-10-29 17:12:14 +00:00
RaresKeY 8b8fc62449 fix(docker): require the patched Debian Brotli decoder (#6956)
* fix(docker): require the patched Debian Brotli decoder

Refresh the signed Debian native Brotli package in a shared build-stage base and fail builds below the security backport floor. Verify the actual selected library, native fix marker, valid decoding, and distro Node linkage before accepting the runtime.

* fix(docker): guard HTTPS trust and accept Bookworm Brotli

Check the inherited system CA store before the shared base makes its first HTTPS package request. Accept the exact fixed Debian 12 marker alongside Debian 13 while retaining package floors, decoder ownership, and valid decoding controls.

* fix(docker): preserve Brotli gate diagnostics
2026-10-29 15:05:58 +01:00
Alexandre Teixeira 0f996f58da Merge pull request #6972 from odysseus-dev/test/env-reference-cli-fixture-performance
test: isolate environment-reference CLI state transitions
2026-10-29 10:31:08 +00:00
Alexandre Teixeira 18329268b0 Merge pull request #6974 from odysseus-dev/test/import-stub-guard-snapshot-performance
test: avoid tuple-list allocation in the import-stub guard
2026-10-29 10:30:50 +00:00
Alexandre Teixeira 6cc98f34b8 Merge pull request #6740 from odysseus-dev/codex/review-7011-preview-ui-20261015
fix(ui): integrate streaming, model picker and Notes updates
2026-10-29 10:29:51 +00:00
Alexandre Teixeira c493a66101 Merge pull request #6960 from odysseus-dev/test/remove-duplicate-editor-cache-check
test: remove duplicate editor cache-name check
2026-10-29 10:28:06 +00:00
Alexandre Teixeira d5949c0286 Merge pull request #6961 from odysseus-dev/test/restore-regression-test-sensitivity
test: restore active-document and owner-isolation sensitivity
2026-10-29 10:26:18 +00:00
Alexandre Teixeira 9ec897d947 Merge pull request #6964 from odysseus-dev/test/imap-concurrent-delete-regression
test: simulate concurrent IMAP deletion at the protocol boundary
2026-10-29 10:25:48 +00:00
Alexandre Teixeira 9257f0e6d0 Merge pull request #6965 from odysseus-dev/test/admin-route-authorization-regressions
test: enforce behavioral admin authorization on listings and diagnostics
2026-10-29 10:25:36 +00:00
Alexandre Teixeira a334a21d57 Merge pull request #6967 from odysseus-dev/test/chat-document-owner-context
test: protect chat document context owner boundaries
2026-10-29 10:24:53 +00:00
Alexandre Teixeira 87ef52f7cc test: avoid tuple-list allocation in the import-stub guard 2026-10-29 08:54:46 +00:00
Alexandre Teixeira 25617a8c10 test: isolate environment-reference CLI state transitions 2026-10-29 08:44:19 +00:00
Alexandre Teixeira 4992cc620f test: protect chat document context owner boundaries 2026-10-29 02:09:37 +00:00
Alexandre Teixeira 59cca6b915 fix(upload): canonicalize confinement on symlinked upload roots 2026-10-29 02:02:47 +00:00
Alexandre Teixeira 1ddda10716 test: exercise real admin gates before listing data 2026-10-29 01:58:21 +00:00
Alexandre Teixeira 958848f9f8 test: interleave concurrent IMAP deletion before expunge 2026-10-29 01:51:41 +00:00
Alexandre Teixeira 571ac09cea fix(security): sanitize file_id and enforce strict prefix confinement on upload paths 2026-10-29 01:39:41 +00:00
Alexandre Teixeira f979e5602f fix(upload,sw): sanitize and confine upload paths and align service worker precache token 2026-10-29 01:35:07 +00:00
Alexandre Teixeira 0f95032177 test: restore active-document and owner-isolation sensitivity 2026-10-29 01:29:04 +00:00
Alexandre Teixeira b3e2e628ab fix(upload,ui): confine thumbnail paths and align stylesheet cache-bust token 2026-10-29 01:25:07 +00:00
Alexandre Teixeira f1a45e85c5 test: remove duplicate editor cache-name assertion 2026-10-29 01:19:32 +00:00
Alexandre Teixeira f843a3dc01 merge(dev): integrate latest upstream dev (PR #6498 omit temperature from subscription requests) 2026-10-29 01:12:31 +00:00
Alexandre Teixeira 72ba37af6b merge(dev): integrate reviewed preview UI changes 2026-10-29 01:12:08 +00:00
Raul JuarezandRaul 5bf4524618 fix(chatgpt): omit temperature from subscription requests (#6498)
Co-authored-by: Raul <9117159+raultcj@users.noreply.github.com>
2026-10-29 01:34:52 +01:00
Alexandre Teixeira 1a7c79946b Merge pull request #6741 from odysseus-dev/codex/review-7011-photo-editor-20261015
feat(editor): integrate layer tools, document tabs and editor UI
2026-10-28 21:27:53 +00:00
Alexandre Teixeira 6d102a213f fix(security): confine teardown paths and avoid interpolating exception data in logs
- routes/gallery/gallery_routes.py: use stable diagnostic log message without
  interpolating raw exception, resolver details, tokens, or destination strings.
- tests/e2e/teardown.js: remove environment-variable path overrides from production
  code, strictly confine test options and temp directories, and prevent arbitrary
  directory cleanup.
- Add regression tests covering non-leakage of synthetic tokens/queries/exceptions
  into logs and responses, and path confinement during teardown.
2026-10-28 21:14:43 +00:00
Alexandre Teixeira de8f151632 fix(security): harden trace teardown and gallery error responses
- CodeQL #1202 (tests/e2e/teardown.js): replace execSync shell execution with
  execFileSync, passing python executable and sanitizer arguments separately with
  shell: false. Preserve ODYSSEUS_TEST_PYTHON support, timeout handling, and
  fail-closed deletion.
- CodeQL #1203 (routes/gallery/gallery_routes.py): prevent exception information
  exposure in /api/image/edit-progress by returning a generic rejection response
  without leaking internal hostnames, IPs, resolver errors, or credentials.
- Add regression tests covering paths with spaces, metacharacters, synthetic command
  substitutions, process failure modes, fail-closed artifact purge, and SSRF
  rejection confidentiality.
2026-10-28 21:10:12 +00:00
Alexandre Teixeira cce5892eeb test(css): update approved photo editor visual baseline
Record the Product Owner-approved 13 element changes across 48 page/variant
combinations for PR #6741. The regenerated baseline exactly matches the
visually reviewed candidate; retain the upstream PR #6840 sidebar styles.

Validation: 49 CSS snapshot/layout tests and 26 frontend/mobile browser tests.
Visual acceptance evidence remains outside the repository.
2026-10-28 20:56:09 +00:00
Alexandre Teixeira f4e3ae6e68 test(security): allowlist synthetic dummy api key in trace sanitizer test fixture
- Add gitleaks allowlist rule for synthetic test string in tests/test_trace_artifact_sanitizer.py
- Resolves Gitleaks generic-api-key false positive on GitHub Actions CI
2026-10-28 19:52:10 +00:00
Alexandre Teixeira 671cbd79ac merge(dev): integrate latest upstream dev (PR #6840 sidebar animations)
- Merge upstream/dev at 0116f0a62b
- Incorporate upstream sidebar toggle animation and auto-collapse measurement fixes
- Preserve upstream CSS snapshot baseline in tests/css_snapshot/baseline.json
- Retain all PR #6741 photo-editor features, security controls, and mobile fixes
2026-10-28 19:30:57 +00:00
Alexandre Teixeira 2e1c2a343a merge(dev): reconcile photo editor security and upstream changes
- Reconcile conflict resolution in routes/editor_agent_routes.py and src/editor_agent_model.py
- Enforce strict server-authorized endpoint resolution and reject client URL overrides
- Enforce bearer-token privilege separation: delegated API tokens are capped at non-admin scope per src/tool_security.py and cannot inspect private base URLs or probe metadata
- Preserve chat-scope authorization, missing-session 401 rejection, and owner-scoped browser bridge/SSE queues
- Preserve administrator-disabled-tool denylist enforcement introduced by PR #6357
- Fix mobile toolbar flyout hit-testing and stacking context trapping on viewports <= 700px via scoped elevation and mask suppression
- Stabilize project recovery test fixture by awaiting document draft quiescence prior to baseline capture
2026-10-28 19:30:08 +00:00
Alexandre Teixeira 0116f0a62b Merge pull request #6840 from eesoymilk/fix/sidebar-toggle-animation
fix(sidebar): animate the sidebar toggle instead of snapping
2026-10-28 18:15:15 +00:00
Alexandre Teixeira c237a1cb9c feat(editor): harden photo editor security and interactions 2026-10-28 17:37:10 +00:00
dxrpl 6b284d2bc5 test(gallery): enable local image models in the bad-input test 2026-10-28 18:27:00 +01:00
dxrpl 8acaec20ee fix(gallery): answer 400 for a missing or invalid image on the image-edit routes (#6818) 2026-10-28 18:25:04 +01:00
red person 5f18bcf6f8 fix(models): retain valid discovery IDs beside malformed rows 2026-10-28 14:52:12 +03:00
red person 18b6140511 fix(mail): sort CLI message batches by UTC date 2026-10-28 14:39:41 +03:00
red person eeb1107f7b fix(gallery): match stacked CLI filters against AI tags 2026-10-28 14:33:23 +03:00
red person 4795c8d462 fix(group): preserve peer identity when reloading history 2026-10-28 14:27:17 +03:00
red person 5c738a5a8e fix(compare): reject incomplete and errored SSE responses 2026-10-28 13:09:35 +03:00
red person 0ff5d47a8c fix(chat): size user-message editors from wrapped content 2026-10-28 12:03:20 +03:00
red person f780f1dce9 test(email): align document identity guards with picker cache 2026-10-28 10:04:55 +03:00
57a1859f08 fix(security): stop the agent from re-enabling tools an admin disabled (#6357)
* fix(security): stop the agent from re-enabling tools an admin disabled

settings.json:disabled_tools is the only global tool-permission boundary
the Settings > Agent Tools panel offers, yet manage_settings exposed an
`enable_tool` action that removed entries from it. Any agent turn could
undo the admin's restriction, and because the agent reads untrusted web
pages, emails and documents, a planted instruction could re-enable bash,
email or any other tool that had been switched off.

The denylist can now only be narrowed from a turn: `disable_tool` and
`list_tools` keep working, `enable_tool` is refused with a message that
points the user to Settings > Agent Tools, and the action is dropped from
the function schema, the prompt rule and the tool index so models are not
steered into calling it. The free-form set/delete/reset actions were
already unable to reach `disabled_tools`; a test now pins that.

Fixes #5523

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UAB5sz5srMKR7rbeDFtdMH

* fix(settings): close internal API tool-policy bypass

Refuse global tool-policy replacement from internal-token requests even when an admin owner is impersonated, and block the same write in app_api. Route explicit image re-enable requests to list_tools with a Settings pointer. Keep disabled_tools outside general settings actions even if a default is added later. Remove the retired action from capability metadata. Validation: 573 focused tests passed; the running app rejected handler, generic API and encoded-path internal HTTP attempts while preserving human administrator controls.

* fix(mcp): block agent writes to per-server MCP tool policy

PATCH /api/mcp/servers/{id}/tools only checked require_admin, which accepts
the internal tool token, and app_api still forwarded it, so an agent turn
could re-enable MCP tools an admin had switched off. Refuse the internal
token on that route (same check as POST /api/tools) and add PATCH
/api/mcp/servers to the app_api blocklist, with regression tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCbhLNC7mJsunxqfwwshqn

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Afonso Junior <chatgpt@yack.com.br>
2026-10-27 22:50:35 -06:00
red person 1c5853003e fix(email): retain inline image selections before input reset 2026-10-28 07:34:10 +03:00
red person 0f8b137713 fix(email): retain paperclip files when resetting the input 2026-10-28 07:26:05 +03:00
red person 2c5d5005ba fix(skills): wait for source before enabling the editor 2026-10-28 07:06:39 +03:00
red person 901b3ff42d fix(skills): align the chat link editor version 2026-10-28 06:58:54 +03:00
red person c207ed1ed8 fix(skills): size the raw editor from its source lines 2026-10-28 06:54:12 +03:00
red person 8b3a866899 fix(cookbook): ignore empty markers beside completed blobs 2026-10-28 06:31:50 +03:00
Alexandre Teixeira a646b59110 Merge pull request #6738 from odysseus-dev/codex/review-7011-harness-20261015
feat(harness): integrate Ajax routing, email context and memory updates
2026-10-28 01:09:22 +00:00
Alexandre Teixeira 4079f12ae6 fix(editor): resolve preview authorization and CI regressions 2026-10-28 00:31:42 +00:00
red person 4d8d363681 fix(memory): refresh records when opening the panel 2026-10-28 03:11:11 +03:00
red person a7e8a6ef2e fix(memory): reset category filter when its last entry disappears 2026-10-28 02:40:54 +03:00
red person 12631da4d3 fix(images): preserve explicit medium quality requests
Use configured quality only for omitted arguments in generation, MCP and edit helpers. Keep explicit medium choices from being overwritten by a low or high default.
2026-10-28 01:02:48 +03:00
red person 927985f847 fix(fonts): group compound weight and style variants by family 2026-10-28 00:23:18 +03:00
red person 2046ce893f fix(email): linkify URLs and addresses without rewriting anchors 2026-10-28 00:12:23 +03:00
red person 35ef91b25c fix(ui): return to the parent view when closing an item 2026-10-27 23:58:00 +03:00
Alexandre Teixeira 091364f7e6 Merge branch 'dev' into review/pewds-pr6738-terra-20261027
Integrate PR #6739 foundation (backend security, bridge hardening, Python 3.11 test fixture correction) with PR #6738 remediations.
- Reconcile routes/email/email_routes.py harmonizing _LIST_LOCK and _LIST_EPOCH with #6738 cache invalidation and thread-safe mutation.
- Preserve editor-session validation, legacy-browser tool restrictions, image-tool authorization, and email cache concurrency invariants.
2026-10-27 18:12:05 +00:00
dxrpl 91922d854c fix(api): answer 400 for a malformed body on integrations and document export-zip (#6905) 2026-10-27 18:33:23 +01:00
Alexandre Teixeira 0133de6f0e feat(security): apply audited PR #6738 remediations and test hardening
- Enforce vision probe SSRF safe local host validation, SafeProbeBackend TOCTOU elimination, and session owner authorization
- Authorize session titling endpoints against registered owner configuration
- Sanitize sensitive metadata logging from agent loop debug logs
- Add async non-blocking execution for spec router tools and thinking decisions
- Harden email cache invalidation fencing and thread-safe mutation
- Add regression test suites for legacy browser authority, spec contracts, spec router request scoping, and task MCP delivery
2026-10-27 17:25:25 +00:00
dxrpl 641752387b fix(calendar): answer 400 when a CalDAV config body is not a JSON object (#6903) 2026-10-27 18:09:14 +01:00
Alexandre Teixeira 339c6886cb Merge pull request #6739 from odysseus-dev/codex/review-7011-editor-server-20261015
feat(editor): integrate planning, image operations and model selection
2026-10-27 16:35:16 +00:00
Alexandre Teixeira 25c2fad3e1 test(editor): preserve ASGI event loop across bridge requests 2026-10-27 16:20:08 +00:00
Alexandre Teixeira d105fbde95 fix(editor): harden bridge isolation and completion semantics 2026-10-27 15:36:17 +00:00
pewdiepie-archdaemon da8ba69c74 Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 0b8176b11e Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 3246ae5d9c Keep streamed-chat module identity consistently cache-busted 2026-10-26 16:57:01 +00:00
pewdiepie-archdaemon 03baea7700 Preserve owner-scoped compaction and the web-only routing boundary 2026-10-26 16:54:10 +00:00
pewdiepie-archdaemon 020fe02aa0 Scope backend checks to backend behavior and retain ownership regression coverage 2026-10-26 16:53:52 +00:00
pewdiepie-archdaemon 375e263419 Merge backend ownership checks and test ownership cleanup 2026-10-26 16:53:52 +00:00
pewdiepie-archdaemon 8865c975aa Keep chat-editor integration and shutdown checks with the runtime review 2026-10-26 16:53:52 +00:00
pewdiepie-archdaemon a58a5e6632 Merge backend ownership checks and test ownership cleanup 2026-10-26 16:53:52 +00:00
pewdiepie-archdaemon 106b1e7c86 Verify saved action names and steps cannot inject HTML 2026-10-26 16:50:09 +00:00
pewdiepie-archdaemon 49d57a43fe Merge recolor cleanup regression coverage 2026-10-26 16:50:09 +00:00
pewdiepie-archdaemon e67cb8bbb9 Merge recolor cleanup regression coverage 2026-10-26 16:50:08 +00:00
pewdiepie-archdaemon d9797ce10f Cover recolor normalization with pathological whitespace 2026-10-26 16:50:07 +00:00
pewdiepie-archdaemon 2c48e7fdab Verify the Gallery model picker in the UI review that ships it 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 9ab440c4cb Keep Gallery image-model picker tests with the editor client 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon ff1d7df8b0 Merge backend prompt hardening and scoped tests 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 6c2a1e6873 Avoid regex backtracking in prompt cleanup and keep Gallery UI tests with client reviews 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon fa29a86696 Merge backend prompt hardening and scoped tests 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 8cfdb8e2e5 Render saved action labels as text instead of HTML 2026-10-26 16:49:01 +00:00
pewdiepie-archdaemon 871c0c0687 Align the shared stream watchdog with the UI review 2026-10-26 16:36:59 +00:00
pewdiepie-archdaemon 76ab72412d Preserve completion gating and handle explicit idle stream status safely 2026-10-26 16:34:56 +00:00
pewdiepie-archdaemon 5c6bb9798b Preserve completion gating and handle explicit idle stream status safely 2026-10-26 16:34:56 +00:00
pewdiepie-archdaemon 56b85c359c Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon 04ca6c26c3 Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon ae28501e7f Normalize final newlines in integrated review files 2026-10-26 16:31:23 +00:00
pewdiepie-archdaemon fdb2a330d9 Remove duplicate session copy-header helpers after integration 2026-10-26 16:30:56 +00:00
pewdiepie-archdaemon 08452ab5f9 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon e461a6b5c5 Keep chat module URLs consistent across imports and precache 2026-10-26 16:29:17 +00:00
pewdiepie-archdaemon aa91e69b2a Merge the verified editor-server foundation into the runtime review 2026-10-26 16:27:32 +00:00
pewdiepie-archdaemon c54bc1d2d3 Merge the verified editor-server foundation into the client review 2026-10-26 16:27:31 +00:00
pewdiepie-archdaemon 9ab3f70fe4 Integrate gallery document tabs with the updated editor client 2026-10-26 16:27:31 +00:00
pewdiepie-archdaemon 89b41b9224 Use explicit editor endpoints in backend regression fixtures 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon 22144050fe Update canonical Notes modules alongside their deployment files 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon e8551e9cbc Use explicit editor endpoints in backend regression fixtures 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon 3d47888559 Use explicit editor endpoints in backend regression fixtures 2026-10-26 16:23:29 +00:00
pewdiepie-archdaemon ace6be44f3 Merge current dev and refresh PR #6740 from the 7011 integration 2026-10-26 16:21:34 +00:00
pewdiepie-archdaemon 36a71886bb Merge current dev and refresh PR #6741 from the 7011 integration 2026-10-26 16:21:28 +00:00
pewdiepie-archdaemon 39ca749f94 Refresh configuration reference for the integrated review scope 2026-10-26 16:21:22 +00:00
pewdiepie-archdaemon 444b60d14e Merge current dev and refresh PR #6738 from the 7011 integration 2026-10-26 16:21:22 +00:00
pewdiepie-archdaemon 93da30c4af Merge current dev and refresh PR #6739 from the 7011 integration 2026-10-26 16:21:17 +00:00
4d0610a1eb fix(email): guard user-supplied IMAP/SMTP hosts against SSRF (#6355)
* fix(email): guard user-supplied IMAP/SMTP hosts against SSRF

POST /api/email/accounts/test opened raw imaplib/smtplib connections to
whatever host:port the request body named, and POST/PUT /api/email/accounts
and PUT /api/email/config persisted arbitrary hosts for the background
pollers to contact. All of these sit behind require_user/require_owner with
no admin gate, so any authenticated user could make the server connect to
loopback, LAN and cloud-metadata targets (169.254.169.254) and read the
first 200 characters of the remote banner back out of the friendly error.
THREAT_MODEL.md lists email as admin-only; the tool layer honours that but
the HTTP routes did not.

Add check_outbound_host() to src/url_safety.py, a bare-host companion to
check_outbound_url() with the same classification (link-local, multicast,
reserved and unspecified always rejected; private/loopback only with
block_private) plus localhost-name handling and an explicit policy for names
that do not resolve. Wire it into the four email routes via
_check_mail_host(): non-admin owners (and everyone when
EMAIL_BLOCK_PRIVATE_IPS=true) also lose private/loopback targets, admins
keep the local-first LAN/Dovecot setups. Unresolvable names pass through
because the connect that follows fails with the same DNS error.

Regression tests cover the helper's policy table and each route: the test
route rejects before any socket opens, save routes reject before touching
the DB, admins keep loopback unless locked down, and an update that fails
validation leaves the stored row untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwcwfY2uQMxNRsK3y6xBAp

* fix(email): harden IMAP/SMTP SSRF protection against rebinding, TOCTOU, and unvetted hosts

- P0: Implement safe_create_connection() in src/url_safety.py to resolve hostnames once, validate every returned address against SSRF policy, and connect directly to the pinned IP. This eliminates DNS rebinding and TOCTOU while preserving TLS SNI and certificate hostname verification across IMAP, IMAPS, STARTTLS, SMTP, and SMTP_SSL.
- P1: Ensure unresolvable or deferred hostnames cannot become an unrestricted future connection capability by enforcing runtime SSRF validation fail-closed on every connection, while maintaining compatibility with offline configurations.
- P2: Extend SSRF enforcement to the core connection layer (_open_imap_connection, _send_smtp_message, _imap_connect) using ContextVar owner scoping, protecting pre-existing DB rows, restored configs, background pollers, and scheduled operations.
- P3: Allow admin and single-user access to IPv6 loopback (::1, [::1]) for local mail servers (e.g. Dovecot) by checking loopback before is_reserved, while keeping link-local, cloud metadata (169.254.169.254, 100.100.100.200), and non-admin restrictions strictly enforced.
- Add comprehensive regression and adversarial test coverage in tests/test_email_account_host_ssrf.py.

* fix(email): reject unresolved saved hosts without stdlib monkeypatches

Use the existing explicit policy IMAP/SMTP classes and canonical connect_outbound_tcp helper. Remove process-wide stdlib assignments, context-variable plumbing, redundant aliases and resolver-signature retries. Reject unsafe or unresolved hosts before account/config persistence, keep DNS work off the event loop, return fixed client errors, identify Alibaba metadata accurately and reject invalid TCP ports. Validation: 654 relevant email, URL-safety and CodeQL regression tests passed, 3 skipped. Three unrelated browser checks were excluded after environment failures (missing Playwright browser and Chromium timeout). Running-app checks verified rejected create/update/config requests leave stored data unchanged, administrator LAN configuration still works, and metadata/non-admin loopback are blocked.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: PR #6029 Reviewer <reviewer@odysseus-review.local>
Co-authored-by: Afonso Junior <chatgpt@yack.com.br>
2026-10-26 09:47:03 -06:00
be9f575b59 fix(security): block read-only cookbook tools for non-admin owners (#6353)
* fix(security): block read-only cookbook tools for non-admin owners

list_cached_models, list_served_models, tail_serve_output, list_downloads,
search_hf_models, list_serve_presets and list_cookbook_servers were in the
plan-mode read-only allowlist but in neither NON_ADMIN_BLOCKED_TOOLS nor the
dispatcher's _ADMIN_TOOLS. Each implementation calls a require_admin route
over the internal loopback with the process-wide INTERNAL_TOOL_TOKEN and
never checks the owner, so a non-admin user's agent inherited admin reach
through them. list_cached_models is the worst case: a caller-chosen `host`
is forwarded to /api/model/cached, which SSHes to that host with the app
user's keys and walks caller-chosen model_dirs. THREAT_MODEL.md lists model
serving as admin-only, so gate the read side the same way as serve/download.

Adding the names to NON_ADMIN_BLOCKED_TOOLS also hides them from non-admin
tool schemas and caps bearer-token agent runs; _ADMIN_TOOLS blocks them at
dispatch. Regression test covers both lists, schema hiding, and that the
loopback is never reached for a non-admin owner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwcwfY2uQMxNRsK3y6xBAp

* fix(cookbook): bind request authority in admin gate regressions

Keep all seven read-only Cookbook tools in the admin prompt inventory. Grant request authority in dispatch tests so the admin gate itself is exercised, and verify administrators still reach each handler. Validation: 359 focused authorization, tool-policy and schema tests passed. Against the running app with real auth accounts, all seven tools reject a non-admin and an admin reaches the authenticated Cookbook HTTP API.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Afonso Junior <chatgpt@yack.com.br>
2026-10-26 09:46:57 -06:00
GrzegorzandClaude Opus 5.5 1000b6582c fix(agent): give file tools the agent workspace as root when no workspace is set (#6722)
* fix(agent): give file tools the agent workspace as root when no workspace is set (#6679)

With no workspace bound, bash/python run in data/agent_workspace, but
RequestAuthority sealed no filesystem root, so every native file tool
failed with "requires a sealed resource root", and an approved
write_file failed with "no sealed resource identity". Seal the agent
workspace instead, and resolve relative paths in _resolve_tool_path
against it rather than the server's cwd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: re-seal resource roots when a test swaps the authority owner (#6679)

A no-workspace authority now seals the agent workspace for its owner, so
replace(authority, owner="bob") copied alice's root and was rejected
before the approval-binding check the tests are about.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(agent): only seal a vetted agent workspace as the file-tool root (#6679)

Apply the agent_cwd() check before sealing data/agent_workspace: it must
be a real directory in the readable data subdirs, otherwise no root is
sealed. A symlinked workspace no longer redirects the file tools or the
bash/python launch scope. Tighten the dispatch test assertions to the
exact 'outside the sealed roots' message.

* refactor(agent): vet the no-workspace file root with default_agent_workspace (#6679)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 09:46:52 -06:00
red person b3f86a0724 fix(forms): preserve Unicode field names during PDF export 2026-10-26 14:04:59 +03:00
red person 94dc74e500 fix(checkin): avoid duplicate calendar events at window boundaries 2026-10-26 12:00:12 +03:00
red person fd27e9a429 fix(skills): preserve version strings across markdown saves 2026-10-26 10:32:26 +03:00
pewdiepie-archdaemon 30430acfea fix(harness): ignore dotted email local parts in artifact inference 2026-10-25 17:04:43 +00:00
Yu-Wei Chang 76abce349b fix(sidebar): measure the auto-collapse restore with the sidebar at full width
When the window widens after an auto-collapse, the restore check removes
.hidden and measures the chat width to decide whether the sidebar fits.
html.ody-sidebar-mini also forces the sidebar to width 0 and was only
cleared by syncRailSide() after the measurement, so the check always saw
a 0-width sidebar and restored it even when the chat ended up narrower
than MIN_CHAT_WIDTH. Sync the mode classes before measuring.
2026-10-24 22:53:45 -07:00
Yu-Wei Chang 4954cc2e8c fix(sidebar): animate the sidebar toggle instead of snapping
The global .hidden utility (display: none) also matches #sidebar.hidden,
so the collapsed sidebar was removed from layout before its width/opacity
transition could run, and opening and closing both snapped in one frame.

Keep the collapsed sidebar rendered and take it out of interaction once
closed: .sidebar.hidden stays display: flex, gains pointer-events: none,
and visibility: hidden that is transitioned so it only flips after the
collapse finishes (keeping the closed sidebar out of the tab order).

On desktop the icon rail now tucks under the sidebar and slides out as it
collapses, so the combined width eases instead of jumping by 48px when
the two swap. On mobile the drawer keeps its width while off so it slides
out as well as in, and switching it to the right edge before opening no
longer animates it across the screen. Transitions are disabled under
prefers-reduced-motion, and the auto-collapse restore measures the chat
width with the transition suppressed.

Re-record the computed-style snapshot baseline for the sidebar and icon
rail.
2026-10-24 22:26:40 -07:00
dxrpl bf3c000d1c fix(tasks): refuse cron expressions with a seconds field (#6824) 2026-10-24 23:41:29 +02:00
PR #6029 Reviewer 7fd4e0e9bb fix(ui): harden iOS safe-area and viewport regressions 2026-10-20 11:39:51 +01:00
dxrplandClaude Opus 5.5 8cd96aad46 fix(cookbook): bring back the Dependencies recipe-panel caret (#6747)
cb052eee removed the caret that opens a backend's recipe panel, saying
the action had moved into the Installed menu, but that menu never got a
recipe item. The panel (model picker, commands, Run) could not be
opened, and the HW Fit / serve-card deep links that click the caret
only flashed the row. Render the caret again on every recipe row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:56 +02:00
pewdiepie-archdaemonandClaude Opus 5.5 5fac693c1c feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).

Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:35 +00:00
pewdiepie-archdaemonandClaude Opus 5.5 eb940f24a3 feat(photo-editor): NL editor planner, image ops, email/gallery server
Server-side photo editor: natural-language editor agent route
(/api/editor/agent), photo_editor tool family + style guide, image-op
provider proxy with progress, email server hardening, gallery/hwfit
routes, and graceful shutdown.

Source: vendor/photocraft (ad1cff44) editor-server files, merged onto
upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/B_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:57:00 +00:00
dxrplandClaude Opus 5.5 6123b70b5c fix(hwfit): keep non-LLM HF collection models out of the LLM catalog (#6731)
Tracked HF collections also contain time-series forecasters, image
encoders and image/video generators. They were imported as "general"
LLMs and offered for vllm serve. Skip their pipeline tags; embedding,
reranker and RL-tagged LLM repos stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:15:08 +02:00
dxrplandClaude Opus 5.5 bfa514f6e8 fix(hwfit): keep the MoE active size for HF collection models (#6729)
_entry_from_collection_item parsed the repo name (the only source of the
"-A3B" active size) only when HF's numParameters was missing, which is
almost never. Every MoE model from the HF collection catalog was scored
as dense, with speed estimates 3-4x too low.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 01:11:46 +02:00
pewdiepie-archdaemonandClaude Opus 5.5 9bdea753ae fix(harness): ship coherent vendor harness cluster
Rebuild PR C to ship the vendor/photocraft harness as a coherent unit
(vendor implementation + vendor tests) instead of a per-file ahead/behind
mix that produced broken imports and 6+ test failures.

- Flip 36 harness impl/route/test files back to vendor (the per-file
  line-set policy misclassified user-rewritten files as 'behind').
- Flip core/models.py + src/tool_approval_scopes.py to vendor: vendor's
  harness (authority.py, turn_contract.py, clean_agent_preview.py) depends
  on vendor's older approval-scope API; upstream added
  CHAT_SESSION_APPROVAL_SIGNATURE_FIELD that vendor's core/models.py lacks.
- Add src/shutdown.py (new vendor infra) required by routes/chat_routes.py.
- Keep src/agent_tools/__init__.py at upstream (editor_tools registration
  lives in PR B).

Upstream-only security divergence preserved in C_upstream_delta.patch and
flagged for integration-time reconciliation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:59:04 +00:00
pewdiepie-archdaemonandClaude Opus 5.5 8fda203d28 feat(ui): chat frontend, model picker, streaming, admin, a11y
Preview/UI work: chat renderer, streaming/think-trace, model picker
(instant-open/refresh-backoff), admin panel, a11y, gallery landing.

Source: vendor/photocraft (ad1cff44) static files, 3-way merged onto
upstream/dev (a8c147b2). Resolution policy in pr_work/D_conflicts.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:42:33 +00:00
pewdiepie-archdaemonandClaude Opus 5.5 364229f2dc feat(harness): authority, turn contract, tools, clean_agent_preview
Chat/agent harness work: request-authority model, turn contract, tool
policy/parsing/execution, clean_agent_preview streaming/thinking, ask_user
persistence, session probes, and scheduler.

Source: vendor/photocraft (ad1cff44) harness files, 3-way merged onto
upstream/dev (a8c147b2). Harness core files were independently developed
in both lineages; resolution policy documented in pr_work/C_conflicts.md
(vendor kept where ahead, upstream kept where vendor is behind; full delta
preserved in pr_work/C_upstream_delta.patch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:41:53 +00:00
VykosMolt 1ff2fd0054 Merge dev and retain native image prompt validation 2026-10-09 01:34:21 +02:00
robyroro ad3e309074 fix(chats): preserve saved conversations during tidy 2026-10-08 06:48:41 +03:00
robyroro 15c4cef36a fix(notes): honor explicit null when clearing optional fields 2026-10-07 19:50:03 +03:00
harshit-ojha0324 84cdd866c5 fix(llm): stop the repeat guard from cutting off numeric data
The degenerate-stream guard treats a long run of one token, a dominant
token, or a periodic window as token collapse. Numbers repeat that way
by design: an 8x8 zero or identity matrix in LaTeX, a padded buffer, a
sparse 0/1 list. Each of those was stopped mid-answer with 'started
repeating tokens'.

Skip the same-run and dominance checks when the repeated token is a
number, skip the periodic check when the recent window is mostly
numbers, and skip the exact-block check when the block is all numbers.
A run of 2048 numbers in a row still stops, so a model stuck printing
'0 0 0' is still caught. Word loops and mixed word/number loops are
unchanged.

Fixes #5560
2026-10-06 19:01:12 -04:00
harshit-ojha0324 50b05bf54f fix(mcp): close the HTTP transport when a remote connect fails
_connect_http entered streamablehttp_client and ClientSession on an
AsyncExitStack but never closed it when initialize or list_tools
failed. The half-open transport was left to the garbage collector,
which exited its AnyIO cancel scope from the wrong task, and the event
loop kept spinning: about 18% of a core per failed connect, growing
with each one, until restart.

Close the stack in the connecting task on any failure or cancellation,
the same way _connect_sse already does.

Fixes #5518
2026-10-06 19:01:10 -04:00
VykosMoltandClaude dbfc96b8f3 fix(tools): add the native function schema for generate_image
generate_image existed in the prompt sections, the executor and the image_gen
MCP server, but not in FUNCTION_TOOL_SCHEMAS. API models are only sent schemas
from that list, so the tool selector could pick it and the schema filter would
drop it again — the model then improvised text-shaped calls it had no way to
make work.

The existing JSON fallback in function_call_to_tool_block already produces
content the executor parses, so no converter branch is needed. A required-args
entry is, though: without it a prompt-less call falls through to the line
parser and the whole JSON blob becomes the image prompt.

Fixes #5520

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-05 02:42:07 +02:00
Alexandre Teixeira a7c03f85bf fix(cookbook): recover from stale llama-server shims
Reconcile Odysseus-owned Python compatibility shims when llama_cpp.server is no longer available, allowing healthy native llama-server binaries in PATH to be discovered and executed without shadowing.

Fixes #6079
2026-08-17 00:59:13 +01:00
753 changed files with 92281 additions and 3871 deletions
+11
View File
@@ -105,6 +105,17 @@ SEARXNG_INSTANCE=http://localhost:8080
# trusted single-user setups that accept injected content reaching side-effect tools.
# ODYSSEUS_TOOL_APPROVAL_GATE=1
# Comma-separated list of exact qualified MCP read and write tool names
# on host-local servers. Only explicitly configured stdio MCP servers qualify.
# Declarations do not override built-in tool effects. Declared reads remain
# untrusted (EXTERNAL_UNTRUSTED) to prevent prompt injections from reaching shell
# or network tools, but allow continuation to declared non-destructive write tools
# on the exact same stdio server without repeated approvals. Stdio transport does
# not guarantee network isolation. Authorization must still originate from the
# trusted request. Automatic same-store writes carry residual prompt-injection risk.
# ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__notes__search_notes,mcp__notes__read_note
# ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__notes__create_note,mcp__notes__update_note
# Mark session cookies Secure. Left unset, this follows the request scheme:
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
# force it on, or false to force it off while you still serve plain HTTP.
@@ -14,6 +14,8 @@ body:
If your idea needs community input before it becomes a concrete proposal,
start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead.
How a proposal is triaged and decided: [product and feature proposals](https://github.com/odysseus-dev/odysseus/blob/dev/docs/maintainers/product-and-features.md#feature-proposal-workflow).
- type: checkboxes
id: prerequisites
attributes:
@@ -79,6 +81,32 @@ body:
label: Prior Art / Related Issues
description: Link any related issues, discussions, or external references that informed this proposal.
- type: dropdown
id: scope
attributes:
label: Size and reversibility
description: Your best guess; the triager decides. Definitions are in the maintainer handbook (product-and-features.md).
multiple: true
options:
- "Small, reversible change within ROADMAP"
- "Big (more than one PR, or changes existing default behaviour)"
- "New direction (new area, multi-user, data leaving the machine, ...)"
- "Hard to undo (migration, deleted data, changed contract or permissions)"
- "Not sure"
- type: textarea
id: decision_questions
attributes:
label: Questions for the Product Owner
description: If this needs a product decision, the exact yes/no questions — one per independent part.
placeholder: "1. Should notes be shareable between accounts on one instance? (yes/no)"
- type: textarea
id: evidence
attributes:
label: Screenshots, recording or demo
description: For anything that renders, attach screenshots or a short recording. Link a demo branch if you have one.
- type: dropdown
id: willing_to_implement
attributes:
+3
View File
@@ -13,6 +13,9 @@
Private maintainer-preview PRs may instead use:
N/A — maintainer integration work
New features: the linked issue carries `product: approved` or
`product: maintainer decision` (docs/maintainers/product-and-features.md).
-->
Fixes #
+25
View File
@@ -179,6 +179,7 @@ jobs:
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
python scripts/check_ca_trust.py --python
uv pip install --system -r requirements.txt
uv pip install --system "playwright==1.62.0"
if: steps.docs-check.outputs.docs_only != 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.docs-check.outputs.docs_only != 'true'
@@ -256,3 +257,27 @@ jobs:
env:
PYTEST_SHARD: ${{ matrix.shard }}
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
- name: Playwright mobile layout checks (shard 1/4 only)
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
run: |
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
--config tests/e2e/ios-layout.config.cjs \
--reporter=line
benchmark-gfm-security:
name: Benchmark GFM rendering security
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
# Test-only renderer, pinned independently of application requirements.
- name: Install GitHub Flavored Markdown renderer
run: |
python -m pip install --disable-pip-version-check --only-binary=:all: "cmarkgfm==2025.10.22"
- name: Verify rendered benchmark report security
run: python tests/test_audit_c5_benchmark_evidence.py --gfm
+2
View File
@@ -53,6 +53,8 @@ services/data/
# Test capture artifacts (browser session dumps may contain personal content)
.playwright-mcp/
test-results/
playwright-report/
# OS
.DS_Store
+12
View File
@@ -35,3 +35,15 @@ regexTarget = "secret"
regexes = [
'''^7abc5b334a6c3c52dd3eba15d8bc516da728a67f14fb7f56d0bb5992d89a16ca$''',
]
[[allowlists]]
description = "Synthetic dummy API key in trace artifact sanitizer test fixture"
targetRules = ["generic-api-key"]
condition = "AND"
paths = [
'''(?:^|/)tests/test_trace_artifact_sanitizer\.py$''',
]
regexTarget = "secret"
regexes = [
'''^sk-123456789$''',
]
+107 -63
View File
@@ -1,28 +1,50 @@
# Contributing to Odysseus
Thanks for helping. The project is moving quickly, so the best contributions are focused, easy to review, and easy to test.
Thanks for helping improve Odysseus. The best contributions are **focused, reviewable, and backed by meaningful validation**.
> [!IMPORTANT]
> **Public pull requests target `dev`, not `main`.** Search existing work before implementing, and follow the current [PR template](.github/pull_request_template.md). Runtime changes require relevant execution evidence, not just a green CI badge.
## At a glance
| Topic | What contributors should do |
| --- | --- |
| **Branch** | Target `dev`; `main` is the curated release branch |
| **Scope** | One coherent change per PR; coordinate larger modularization work through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Validation** | Document commands, results, runtime checks, and anything not tested |
| **Visual changes** | Run the UI, match the existing visual language, and attach screenshots or clips |
| **Review** | Follow the [risk-based maintainer policy](docs/maintainers/changes-and-review.md) and applicable repository protections |
| **Security** | Report vulnerabilities privately using [SECURITY.md](SECURITY.md) |
**Jump to:** [Branches](#branch-model) · [Getting started](#before-you-start) · [Setup](#setup) · [Checks](#running-checks) · [Pull requests](#pull-requests) · [Modularization](#structural-changes-and-modularization) · [Visual changes](#style-and-visual-changes) · [Code conventions](#code-conventions) · [Issue reports](#issue-reports)
---
## Branch model
Odysseus has two branches:
| Branch | Purpose |
| --- | --- |
| **`dev`** | Default integration branch and target for every public PR. Work can be in flux. |
| **`main`** | Curated release branch. A [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership) promotes a verified `dev` commit. |
- **`dev`** — where all PRs land. Things can be in flux here; the merge button gets used freely.
- **`main`** — what users run. Curated and tested by the release's [Release Maintainer](docs/maintainers/testing-and-releases.md#release-ownership). Fast-forwarded to a stable `dev` commit at each release.
If your PR accidentally targets `main`, use **Edit** on GitHub to change its base to `dev`. Changing the base does not itself require a rebase.
**Open your PR against `dev`, not `main`.** The GitHub "base" dropdown defaults to `dev`. If you opened a PR against `main` by accident, click "Edit" on the PR and change the base — no rebase needed.
End-users cloning the repo will land on `dev` by default. To run the curated/stable version: `git checkout main` after clone.
End users cloning the repository land on `dev` by default. To use the curated release branch, run `git checkout main` after cloning.
## Before You Start
- Search existing issues and pull requests before opening a new one.
- Prefer one bug fix or feature per pull request.
- Avoid broad rewrites, formatting-only changes, or moving many files unless the issue is specifically about structure.
- If you want to work on a large feature, open an issue first and describe the approach.
1. Search [open issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls) to avoid duplicate or conflicting work.
2. Prefer one bug fix, feature, or coherent refactor per PR. Avoid broad rewrites, formatting-only churn, and unrelated file moves.
3. For a feature, open a **Feature Request** first. Big, new-direction, or difficult-to-reverse changes require the documented [product decision process](docs/maintainers/product-and-features.md#feature-proposal-workflow).
4. For structural work, coordinate the intended module boundary with maintainers and the [modularization tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
> [!NOTE]
> **Coding agents:** If you use an LLM coding agent, open an issue describing the problem before submitting its PR. Bulk agent-generated submissions that ignore the project's contribution format or visual style may be closed without review, even when the underlying fix is correct.
## Setup
Docker is the recommended path for normal testing:
<details open>
<summary><strong>Docker (recommended for normal testing)</strong></summary>
```bash
git clone https://github.com/odysseus-dev/odysseus.git
@@ -31,7 +53,10 @@ cp .env.example .env
docker compose up -d --build
```
Manual development uses Python 3.11+:
</details>
<details>
<summary><strong>Manual Python development (Python 3.11+)</strong></summary>
```bash
python3 -m venv venv
@@ -40,11 +65,14 @@ pip install -r requirements.txt
python -m uvicorn app:app --host 127.0.0.1 --port 7000
```
Windows is not actively tested. Docker on Linux or a Linux/macOS manual install is the safer path for now.
</details>
> [!NOTE]
> Native Windows is not actively tested. Docker on Linux or a Linux/macOS manual installation is the safer path for now. Document the environment actually used for testing.
## Running Checks
Run the smallest relevant checks for your change:
Run relevant checks for the affected area. Examples from the existing contributor workflow:
```bash
python -m pytest
@@ -60,78 +88,94 @@ docker compose up -d --build
docker compose logs --tail=120 odysseus
```
Mention what you ran in the pull request description. If you could not run a check, say so.
> [!IMPORTANT]
> **Report what actually ran.** Include commands, results, and relevant manual/runtime verification in the PR description. If a check was not run, mark it as **not run** and explain the limitation. Passing syntax checks and unit tests do not automatically prove changed behavior works in the running application.
See the [testing and releases handbook](docs/maintainers/testing-and-releases.md) for risk-based validation and isolation requirements.
## Maintainer governance and review guidance
For repository roles, product decisions, risk-based approvals, issue triage, independent security reviews, CodeQL findings, and release handoffs, see the [Odysseus maintainer handbook](docs/maintainers/README.md). This complements the contributor requirements below and does not change the branch model or current PR template.
The [Odysseus maintainer handbook](docs/maintainers/README.md) is authoritative for repository roles, product decisions, risk-based approvals, issue triage, security review, CodeQL findings, and release handoffs.
> [!NOTE]
> The handbook complements this contributor guide. It does not override the branch model, live GitHub protection rules, or the PR template.
## Pull Requests
Good pull requests usually include:
Keep PRs **cohesive**. Small, focused changes are the default. A larger, coordinated module decomposition may be appropriate under the criteria below, but unrelated cleanup, formatting, refactors, and behavior changes must not be mixed.
- A short explanation of the bug or feature.
- The files or areas changed.
- Manual test steps or automated test results from running the actual app, not just the test suite.
- Screenshots or short recordings for UI changes.
- Links to related issues, for example `Fixes #123`.
| Include in the PR | Why it matters |
| --- | --- |
| **Summary and scope** | Explain the problem, intended behavior, and areas changed |
| **Linked issue** | Connect the PR to the existing report or approved work, for example `Fixes #123` or `Part of #4071` |
| **Validation evidence** | Show manual test steps or automated results that exercise the actual behavior |
| **UI evidence, when applicable** | Attach screenshots or short recordings from the running application |
| **Limitations** | Clearly identify tests or environments that were not exercised |
Please keep PRs small. Large PRs that mix unrelated cleanup, formatting, refactors, and behavior changes are much harder to review.
For exact required sections and attestations, follow the current [PR template](.github/pull_request_template.md).
> **Auto-generated PRs.** If you are running an LLM agent (Devin, Cursor, OpenHands, Claude Code, etc.) against this repo: please open an issue describing the problem first instead of opening a PR directly. Bulk agent-generated PRs that don't match the project's visual style or contribution format will be closed without review, even when the underlying fix is correct.
### Structural changes and modularization
**One coherent responsibility is the unit of review.** A larger PR can be appropriate when extracting that responsibility from an oversized module is safer or simpler than splitting the work into many conflicting or interdependent PRs.
Before implementation and review:
- **Coordinate:** Read existing issues/PRs and agree on the boundary with maintainers. Use [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) for codebase modularization.
- **Constrain scope:** Keep one subsystem or responsibility per PR. Avoid unrelated features, formatting, and opportunistic cleanup.
- **Preserve contracts:** Maintain observable behavior, imports, public interfaces, persistence contracts, and runtime invariants unless an approved issue explicitly scopes a change.
- **Validate behavior:** Include targeted regression tests and runtime checks. Frontend work needs browser validation and screenshots or recordings when rendering may change.
- **Explain tradeoffs:** Describe extraction boundaries, compatibility, validation results, and known limitations in the PR.
> [!TIP]
> **Large does not mean broad.** There is no arbitrary line-count limit for a well-scoped extraction. Correctness, reviewability, and integration risk determine whether the scope is acceptable.
> [!CAUTION]
> **Temporary Harness V2 coordination boundary:** Unrelated changes to `src/agent_loop.py` and closely related execution, authorization, orchestration, and context contracts are temporarily frozen while Harness V2 is developed and validated. Coordinate such work with maintainers. Other modules remain open, and urgent bug or security reports are welcome.
These contributions remain subject to the [risk-based review policy](docs/maintainers/changes-and-review.md), current repository protections, and PR template.
## Style and visual changes
Odysseus has an intentional visual style. PRs that ignore it will be closed without merge, no matter how correct the underlying code is.
Odysseus has an intentional visual language. Changes that ignore it can be closed without merge even when their underlying code is correct.
Before submitting any change that affects what the app looks like — buttons, icons, fonts, colors, spacing, layout, CSS, HTML, SVG, or any `static/js/` module that draws to the DOM — please:
For any change affecting buttons, icons, typography, colors, spacing, layout, CSS, HTML, SVG, or JavaScript that renders into the DOM:
1. **Run the app locally** and view the change in a browser. Type-checks and unit tests are not enough.
2. **Attach a screenshot or short clip** of the change in the running app. Add a mobile screenshot too if the change affects mobile.
3. **Match the existing visual language.** Specifically:
- Reuse existing CSS variables (`--red`, `--fg`, `--bg`, `--card`, `--border`, …). Do not introduce new color values, font sizes, or spacing units.
- Reuse existing button, input, card, and border classes. Don't invent parallel styling for similar widgets.
- **No Unicode emoji in UI or code.** Use inline SVG (matching the monochrome icon style already in `static/index.html`) or plain text.
- Monospaced font (`Fira Code`) for primary UI text. Don't override.
- Dark theme is the default; any light-mode work goes through the existing theme system, not hard-coded.
4. **Don't add parallel components.** If a similar widget already exists in the app, extend it instead of writing a new one.
1. **Run the application locally** and inspect the change in a browser. Type checks and unit tests alone are insufficient.
2. **Attach a screenshot or short clip** from the running app, plus a mobile screenshot when mobile behavior changes.
3. **Reuse the existing design system:**
- Prefer existing CSS tokens (`--red`, `--fg`, `--bg`, `--card`, `--border`, etc.). Do not introduce parallel colors, type scales, or spacing systems.
- Reuse existing button, input, card, and border classes rather than creating duplicate patterns.
- Do **not** add Unicode emoji in UI or code. Use inline SVG consistent with `static/index.html` or plain text.
- Preserve the existing primary monospaced font (`Fira Code`).
- Keep dark mode as the default; route any light-mode work through the existing theme system.
4. **Extend existing components** instead of introducing a parallel component for the same purpose.
If you are unsure whether a change is "visual," it is. Default to attaching a screenshot.
> [!IMPORTANT]
> If you are unsure whether a change is visual, assume that it is and provide a screenshot. Visual evidence does not replace behavioral validation.
## Code conventions
Don't hardcode values that the project already exposes through a constant or a helper. Hardcoded literals drift out of sync, break on non-default deployments, and reintroduce bugs we've already fixed.
**Use existing constants and helpers.** Hardcoded duplicates drift, break non-default deployments, and can reintroduce previously fixed issues.
- **Filesystem paths:** never build writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Every persisted file and directory has a named constant in `src/constants.py` (for example `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, `CHROMA_DIR`). Import and use that named constant; do not re-derive the path locally with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` is the single place that reads `ODYSSEUS_DATA_DIR`, so use it directly only for dynamic paths that have no fixed name (for example per-owner files). If a data file or directory has no constant yet, add one to `src/constants.py`. The source tree is read-only in Docker and `/app/...` does not exist on native runs; guard directory creation so an unwritable path degrades gracefully instead of crashing at import.
- **Internal API / loopback URLs:** don't hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants` (it honors `ODYSSEUS_INTERNAL_BASE` / `APP_PORT`).
- **Ports, limits, model lists, and similar:** reuse the existing constant if one exists; if it doesn't and the value is used in more than one place, add a constant rather than copying the literal.
- **Filesystem paths:** Do not derive writable paths from `Path(__file__)...` into the source tree, hardcode `/app/...`, or use a relative `"data/..."` string. Persisted files and directories have named constants in `src/constants.py`, such as `AUTH_FILE`, `USER_PREFS_FILE`, `SETTINGS_FILE`, `TTS_CACHE_DIR`, and `CHROMA_DIR`. Import those names rather than deriving paths with `os.path.join(DATA_DIR, "x.json")` or `DATA_DIR / "x.json"`. `DATA_DIR` reads `ODYSSEUS_DATA_DIR` and is intended directly only for dynamic paths without fixed names, such as per-owner files. Add a constant when a fixed path does not yet have one. The source tree is read-only in Docker, and `/app/...` does not exist on native runs. Guard directory creation so unwritable paths degrade gracefully rather than crashing at import.
- **Internal API and loopback URLs:** Do not hardcode `http://localhost:7000`. Use `internal_api_base()` from `src.constants`, which honors `ODYSSEUS_INTERNAL_BASE` and `APP_PORT`.
- **Ports, limits, and model lists:** Reuse existing constants. When a value is shared by multiple locations and has no constant, add one in `src/constants.py` instead of duplicating a literal. `core/constants.py` is a compatibility re-export, not another source of truth.
If you need a value that has no constant or helper yet, add it to `src/constants.py` (the single source of truth for paths and config; `core/constants.py` only re-exports it for backward compatibility) and import it, rather than repeating a literal across files.
**Commits:** use [Conventional Commits](https://www.conventionalcommits.org), `type(scope): summary` (e.g. `fix(search): ...`, `feat(notes): ...`, `docs(contributing): ...`). Common types: `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, `ci`. Keep the subject short and imperative; put the "why" in the body when it isn't obvious.
**Commits:** Use [Conventional Commits](https://www.conventionalcommits.org) with `type(scope): summary`, for example `fix(search): ...`, `feat(notes): ...`, or `docs(contributing): ...`. Common types include `fix`, `feat`, `refactor`, `docs`, `test`, `chore`, and `ci`. Keep the subject short and imperative, with the motivation in the body when useful.
## Issue Reports
For bugs, include:
| Bug reports should include | Model-serving reports should additionally include |
| --- | --- |
| Installation method (Docker, Python, WSL, etc.) | Backend (Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.) |
| OS, browser, and device when relevant | Model name |
| Exact reproduction steps | GPU/CPU and operating system |
| Expected versus actual behavior | Cookbook task logs or backend/server logs |
| Relevant logs, screenshots, or terminal output | Relevant backend configuration |
- Install method: Docker, manual Python, WSL, etc.
- OS, browser, and device if relevant.
- Exact steps to reproduce.
- Expected behavior and actual behavior.
- Logs, screenshots, or terminal output.
For model-serving issues, include:
- Backend: Ollama, vLLM, SGLang, llama.cpp, LM Studio, etc.
- Model name.
- GPU/CPU and operating system.
- Cookbook task logs or server logs.
Issues with only "help", "does not work", or a screenshot without context may be closed as not actionable.
Issues containing only "help", "does not work", or an unexplained screenshot may be closed as not actionable. Start with a reproducible problem or use [Discussions](https://github.com/odysseus-dev/odysseus/discussions) for setup questions.
## Security
Do not post secrets, API keys, private logs, personal documents, or public IPs in issues or pull requests.
For security reports, follow [SECURITY.md](SECURITY.md).
> [!CAUTION]
> **Do not publish secrets.** Keep API keys, sensitive logs, personal documents, and public IP addresses out of public issues and PRs. Report vulnerabilities privately through [SECURITY.md](SECURITY.md).
+18 -3
View File
@@ -1,9 +1,22 @@
# Both stages need the fixed native decoder, including curl in the builder and
# distro Node in the runtime. A cached Python base may predate the Debian fix.
FROM python:3.14.9-slim AS brotli-base
COPY scripts/check_brotli.py /usr/local/bin/check-brotli.py
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
# Check inherited trust before the first HTTPS package request, retaining Debian's
# signed archive verification. Refresh only the explicit native dependency.
RUN python /usr/local/bin/check-ca-trust.py --system \
&& sed -i 's|http://deb.debian.org/|https://deb.debian.org/|g' /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends libbrotli1 \
&& python /usr/local/bin/check-brotli.py \
&& rm -rf /var/lib/apt/lists/*
# ---- builder: patch + build wheels for Real-ESRGAN's broken-on-3.14 deps ----
# basicsr/gfpgan/facexlib read their version via exec()+locals()['__version__'],
# which raises KeyError on Python 3.13+ (PEP 667). Build patched wheels here so
# the final image / Cookbook never has to compile the broken sdists. See
# docker/build-realesrgan-wheels.sh for the full rationale.
FROM python:3.14.9-slim AS realesrgan-wheels
FROM brotli-base AS realesrgan-wheels
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY scripts/check_ca_trust.py /usr/local/bin/check-ca-trust.py
@@ -14,7 +27,7 @@ RUN python -m pip install --no-cache-dir --require-hashes --only-binary=:all: --
COPY docker/build-realesrgan-wheels.sh /usr/local/bin/build-realesrgan-wheels.sh
RUN bash /usr/local/bin/build-realesrgan-wheels.sh /wheels
FROM python:3.14.9-slim
FROM brotli-base
# System deps. tmux is required by Cookbook for background downloads/serves.
# openssh-client is required for Cookbook remote server tests, setup, probes,
@@ -52,6 +65,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0t64 \
libxcb1 \
libmagic1 \
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node \
&& rm -rf /var/lib/apt/lists/*
# Refresh both Python trust bundles before installing application dependencies.
@@ -132,7 +146,8 @@ RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
# Check gRPC's effective defaults after all Python dependencies are installed.
# The application minimum fixes its bundled roots; the checked override also
# keeps the effective Docker selection consistent with system trust.
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc
RUN python /usr/local/bin/check-ca-trust.py --system --python --grpc \
&& python /usr/local/bin/check-brotli.py --node /usr/bin/node
# Copy app code
COPY . .
+104 -74
View File
@@ -1,87 +1,117 @@
# Roadmap / Help Wanted
Odysseus is on a voyage, but not home yet. It works great for me (lol), but this ship is moving fast and feedback/help would be appreciated! (I don't know what I'm doing, help).
Odysseus is an evolving, self-hosted AI workspace. This roadmap highlights where improvements and contributions are most useful, from reliability and maintainability to product capabilities.
If you see weird CSS, strange layout behavior, or a suspiciously murky corner of
the codebase, you are probably right to stay away.
> [!NOTE]
> **Direction, not a release schedule.** This page is not a complete issue backlog or a promise to deliver every proposal. Implementation scope and priority are coordinated through existing issues, pull requests, and the project's [feature proposal process](docs/maintainers/product-and-features.md).
## High Priority
## At a glance
- SQUASH BUGS
- Fresh install smoke tests on Linux, macOS, and Windows. Docker, native Python,
and WSL all need coverage.
| Area | Direction | Coordination |
| --- | --- | --- |
| **Stabilization** | Reliability, CI, fresh installs, security, and integrations | [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) |
| **Modularization** | Clearer module ownership, safer changes, and contributor onboarding | [Canonical tracker #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Agent runtime** | Active Harness V2 development, with a narrow temporary change freeze | [Coordination in #4071](https://github.com/odysseus-dev/odysseus/issues/4071) |
| **Product improvements** | Local models, research, frontend experience, and operations | [Issues](https://github.com/odysseus-dev/odysseus/issues) and [feature proposals](docs/maintainers/product-and-features.md) |
- Integration audit: do integrations even work? Confirm what works, what needs setup docs, and what should be removed or hidden.
- Cookbook reliability on other computers. This is probably the area most likely to need work across different machines, GPUs, drivers, shells, and Python environments.
- Cookbook SGLang support across platforms. Make sure SGLang setup/serve works
predictably on Linux, Windows/WSL, macOS where possible, Docker, and common
NVIDIA/AMD hardware paths.
- Deep Research model presets by hardware. Recommend approved model/parameter
profiles for small, medium, and large local setups so people with different
hardware can use Deep Research without guessing. Surface this either in Deep
Research settings or as a Cookbook scan/dropdown suggestion.
- Cookbook model scan/download ranking. Prioritize newer architectures and
better hardware-fit models instead of scoring everything almost the same.
Ranking should account for architecture age, quant format, VRAM/RAM fit,
backend support, vision/mmproj requirements, and likely serve reliability.
- Cookbook error feedback and logging. Failed downloads, dependency installs,
preflights, and serve jobs should show the actual command/output/error in the
UI, with copyable logs and clear next steps instead of just "crashed".
- Agent prompt/context bloat. Agent mode is too heavy for smaller local models:
tool schemas, skills, memory, documents, and instructions can eat the context
before the user request really starts. We need slimmer prompts, better tool
selection, smaller default tool sets, and clearer guidance for models with
4k/8k/16k context windows.
- Local model speculative decoding support. For Odysseus-tuned local models,
plan to ship or recommend a small same-tokenizer draft model when the serving
backend supports it. Early vLLM testing showed a generic `Qwen3-0.6B` draft
beside `Qwen3-8B` can materially reduce wall time, while an unsupported
DSpark conversion performed poorly. Treat this as a supported draft-model lane
first; keep MTP-specific packaging as future work only when the architecture
and runtime support are real. Judge this by time-to-success, tool correctness,
grammar, and unchanged target output, not tokens/sec alone.
- Skill/tool prompt-injection audit. User-editable skills, notes, documents,
fetched pages, and memories should be treated as untrusted data. Keep testing
whether models follow malicious instructions from those surfaces.
- Better degraded-state reporting for ChromaDB, SearXNG, email, ntfy, and provider probes.
- Email performance audit. Fetching, searching, opening, deleting, and sending
email can feel slow, especially over IMAP/SMTP providers with high latency.
Need someone who knows mail performance to profile the current flow, identify
whether the bottleneck is IMAP folder select/fetch, cache invalidation,
attachment/body loading, SMTP handshakes, or frontend refresh behavior, then
propose safer caching/prefetch/batching without breaking multi-account state.
- Provider setup/probing audit for Anthropic, Gemini, Groq, xAI, OpenRouter, OpenAI, and DeepSeek.
**Jump to:** [Stabilization](#stabilization-and-reliability) · [Modularization](#modularization-and-maintainability) · [AI and local models](#ai-local-models-and-research) · [Frontend](#frontend-and-user-experience) · [Backend](#backend-and-operations) · [Contributing](#contributing)
## Refactor Targets
- CSS cleanup. `static/style.css` basically Calypso's island atm.
- Tour core helper. The onboarding tours have too much copy-pasted scaffolding; promote a shared `tour-core.js` helper before adding more tours.
- Modal/window positioning cleanup. Some window controls have improved, but the
underlying popup/dropdown/fixed-position behavior is still too fragile.
- Mobile media override discoverability. A lot of "CSS did not move" bugs are mobile `@media` overrides of the same selector; comments or linting around desktop/mobile paired rules would help.
- Dead code pass for old routes, stale feature flags, and unused UI states.
---
## Frontend
## Stabilization and reliability
- Expand the Editor for quicker, more robust everyday use. Better file/document
handling, smoother window behavior, clearer save/export flows, stronger image
editing affordances, and fewer brittle edge cases.
- Better AI integration for Notes and Todos. Notes should be easier for the
agent to read, update, summarize, and turn into actions. Todos should be
assignable to an agent from the UI, possibly through a button, task action,
or dedicated skill/tool flow.
- Mobile gallery/editor polish. Easier to launch/download inpaint model or any missing pieces.
- Accessibility pass: keyboard navigation, focus states, contrast, reduced motion.
- Improve empty states and error messages on fresh installs.
- Tighten first-run setup, hints, and tours so they do not repeat or fight each other.
- Vendor CDN assets eventually for a more fully self-hosted/offline mode.
**Priority:** Make Odysseus dependable across installations, platforms, providers, and supported hardware.
## Backend
| Workstream | What needs improvement | Tracking |
| --- | --- | --- |
| **CI and validation** | Exact-revision validation, meaningful regression coverage, and release confidence | [#5932](https://github.com/odysseus-dev/odysseus/issues/5932), [#7109](https://github.com/odysseus-dev/odysseus/issues/7109) |
| **Test quality** | Deterministic tests, isolation, focused execution, and maintainable test structure | [#2523](https://github.com/odysseus-dev/odysseus/issues/2523), [testing standard](tests/TESTING_STANDARD.md) |
| **Fresh installations** | Smoke tests for Linux, macOS, Windows, WSL, and Docker; baseline startup and graceful degradation | [#3968](https://github.com/odysseus-dev/odysseus/issues/3968) |
| **Email integration** | Remaining Google OAuth regressions, account isolation, and cross-provider reliability | [#5637](https://github.com/odysseus-dev/odysseus/issues/5637) |
| **Storage integrity** | Upload payload growth, indexing, and attachment/file lifecycle | [#4889](https://github.com/odysseus-dev/odysseus/issues/4889) |
- More tests around endpoint probing and provider setup.
- Better task scheduler defaults and visibility.
- Backup/restore guide and helper flow for `data/`.
- Security hardening around admin-only tools and clear docs for their risk.
Additional reliability priorities:
## Not The Focus Right Now
- **Cookbook:** Improve model installation, dependency management, hardware detection, and model serving across operating systems, GPUs, drivers, shells, Python environments, and supported backends.
- **Security:** Keep strengthening authorization, administrative tools, agent tool execution, prompt-injection boundaries, filesystem access, and untrusted-content handling.
- **Integrations and degraded states:** Audit supported providers and optional services, including ChromaDB, SearXNG, email, and ntfy. Make failures observable and nonessential outages predictable.
I prob shouldnt add more themes.
> [!TIP]
> Start with an existing, reproducible issue. The [Stabilization & Modularization milestone](https://github.com/odysseus-dev/odysseus/milestone/1) is a curated work queue, not an invitation to implement every item in this roadmap without coordination.
## Modularization and maintainability
The goal is **clearer responsibility boundaries and safer development**, not smaller files for their own sake. Changes should preserve behavior, compatibility, and relevant runtime contracts.
| Area | Current foundation | Remaining opportunities |
| --- | --- | --- |
| **CSS** | The former `static/style.css` has been split into ordered files under `static/css/` | Improve ownership while preserving the cascade, themes, and responsive layouts |
| **Tools** | Substantial tool-domain extraction has landed | Clarify remaining ownership and preserve compatibility through the existing tool interfaces |
| **Frontend** | Existing feature modules remain in use | Decompose large document, gallery, settings, calendar, and other UI modules around coherent behavior |
| **Backend** | Domain-specific routes and services already exist | Identify safe boundaries in Cookbook routes, persistence, and other tightly coupled modules |
| **Tests** | Taxonomy, focused execution, and structural improvements exist | Align fixtures, helpers, and regression coverage with implementation boundaries |
| **UI infrastructure** | Targeted cleanup is underway | Consolidate tour scaffolding, modal/window positioning, responsive overrides, and obsolete paths |
**Canonical coordination:** [Codebase modularization and contributor onboarding, #4071](https://github.com/odysseus-dev/odysseus/issues/4071).
> [!IMPORTANT]
> **Cohesive work is more important than a small line count.** A larger PR may be appropriate for one well-defined decomposition when several smaller PRs would create avoidable conflicts or integration overhead. Coordinate the boundary, avoid unrelated work, and supply regression and runtime evidence. See [CONTRIBUTING.md](CONTRIBUTING.md#structural-changes-and-modularization).
### Agent runtime and Harness V2
Harness V2 is under active development.
> [!CAUTION]
> **Temporary agent-runtime freeze.** Unrelated changes to `src/agent_loop.py` and closely related execution, orchestration, authorization, and context contracts must be coordinated with maintainers while Harness V2 is developed and validated.
>
> This does **not** freeze unrelated application modules. Urgent bug reports and security issues remain welcome. No release date is implied.
## AI, local models, and research
These are **areas of product interest**, not commitments to a specific implementation or delivery order. Follow normal issue triage and product-decision requirements.
| Direction | Opportunity |
| --- | --- |
| **Smaller-model agents** | Reduce unnecessary prompt, tool-schema, skill, memory, and document context overhead; improve tool selection for constrained context windows |
| **Cookbook and SGLang** | Improve installation and serving across relevant hardware, operating systems, and deployment methods |
| **Model discovery** | Rank models by architecture, quantization, RAM/VRAM fit, backend support, multimodal requirements, and expected reliability |
| **Deep Research presets** | Explore suitable model and parameter configurations for small, medium, and large local systems |
| **Actionable diagnostics** | Expose command output, logs, and next steps for failed downloads, dependencies, preflights, and serving jobs |
| **Speculative decoding** | Investigate compatible draft-model configurations and evaluate end-to-end correctness and time to success |
| **Provider setup** | Improve endpoint discovery, capability checks, and setup for supported local and hosted providers |
## Frontend and user experience
- **Editors and files:** Improve document/file editing, window behavior, save/export flows, and image-editing reliability.
- **Notes and Todos:** Expand agent-assisted reading, updates, summarization, and turning information into tasks.
- **Mobile:** Improve gallery/editor usability, model setup, and responsive behavior.
- **Accessibility:** Strengthen keyboard navigation, focus states, contrast, and reduced-motion support.
- **First run:** Improve setup, hints, empty states, error messages, and onboarding tours without duplicate guidance.
- **Self-hosting:** Reduce unnecessary external asset dependencies where practical for offline operation.
## Backend and operations
- **Email performance:** Measure fetching, searching, body and attachment loading, caching, sending, and frontend refresh behavior before optimizing.
- **Provider reliability:** Expand regression coverage for setup, endpoint probing, and degraded-service behavior.
- **Task scheduling:** Improve defaults, reliability, and operational visibility.
- **Data recovery:** Improve backup and restore guidance and workflows for persistent data.
- **Administrative security:** Review privileged capabilities and sensitive integrations against their authorization boundaries.
## Not a current priority
- Additional themes without a demonstrated product need.
- Broad rewrites or framework migrations without clear, measurable value.
- Changes undertaken solely to reduce file counts.
- Uncoordinated changes to temporarily frozen agent-runtime contracts.
---
## Contributing
1. Read [CONTRIBUTING.md](CONTRIBUTING.md) and search [existing issues](https://github.com/odysseus-dev/odysseus/issues) and [PRs](https://github.com/odysseus-dev/odysseus/pulls).
2. Coordinate modularization through [#4071](https://github.com/odysseus-dev/odysseus/issues/4071) and active stabilization work through the [milestone](https://github.com/odysseus-dev/odysseus/milestone/1).
3. Use the repository's issue and PR templates and target **`dev`**.
4. Include relevant tests, runtime evidence, and screenshots or recordings for visual changes.
> [!NOTE]
> Product-direction or difficult-to-reverse changes follow the [feature proposal process](docs/maintainers/product-and-features.md). Report security vulnerabilities privately through [SECURITY.md](SECURITY.md), never in a public issue.
+3 -2
View File
@@ -64,12 +64,13 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). The admin-only `tool_approval_mode` setting (Settings → Agent Tools, `off` = "Off (YOLO)") is the same knob exposed in the UI: with `off`, every run sets `approval_gate_bypassed` and this gate stops asking, exactly as if the env var were off. Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off — by either knob — lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
Two exemptions apply even when the gate is on, both deliberate:
Three exemptions apply even when the gate is on, all deliberate:
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
- Narrow continuation for local MCP tools declared via `ODYSSEUS_LOCAL_MCP_READ_TOOLS` and `ODYSSEUS_LOCAL_MCP_WRITE_TOOLS`: Only explicitly configured stdio MCP servers qualify; remote HTTP and SSE servers fail closed. Declarations never override built-in tool effects, so built-in email, browser, memory, image generation, and other built-in servers cannot receive continuation exceptions. Declared read tools remain untrusted (`EXTERNAL_UNTRUSTED`) to ensure that prompt injections in local data cannot reach shell, network, or unrelated tools without approval. Stdio transport does not guarantee network isolation, as local processes may still reach the host network. However, declared non-destructive write tools on the exact same stdio MCP server are permitted to continue without an approval prompt if the only untrusted context in the run was produced by declared local reads on that same server, without any explicit error or external taint. Genuinely external content (web search, web fetch, emails, external message context) blocks these writes. Authorization must still originate from the trusted request: request authority must permit the tool and retain the sealed backend resource grant. The residual same-store write risk (the agent writing to the same local store after reading a local prompt injection) remains subject to the user's initial request authority.
## Security Headers
+83 -1
View File
@@ -178,6 +178,14 @@ app.add_middleware(SecurityHeadersMiddleware)
import asyncio as _asyncio
from starlette.middleware.base import BaseHTTPMiddleware as _BaseHTTPMiddleware
from starlette.responses import JSONResponse as _JSONResponse
import anyio as _anyio
from starlette.requests import ClientDisconnect as _ClientDisconnect
# SSE endpoints (e.g. /api/editor/bridge/events) end/abort mid-stream when the
# browser disconnects; Starlette's BaseHTTPMiddleware surfaces that as
# anyio.EndOfStream (and sometimes ClientDisconnect). Those are client-side
# events, not server errors, so we map them to a 499 instead of a 500 traceback.
_STREAM_CLIENT_EXC = (_anyio.EndOfStream, _ClientDisconnect)
REQUEST_HARD_TIMEOUT = float(os.getenv("REQUEST_HARD_TIMEOUT", "45"))
_TIMEOUT_EXEMPT_PREFIXES = (
@@ -190,6 +198,7 @@ _TIMEOUT_EXEMPT_PREFIXES = (
"/api/cookbook/setup", # remote pacman/apt installs
"/api/upload", # large files
"/api/image", # diffusion proxies (inpaint/harmonize/upscale/etc.) — own 120s httpx timeout
"/api/editor", # editor agent/bridge: generative steps can take 30-110s (SAM + local diffusion)
"/api/memory/audit", # retains own 120s LLM inactivity timeout
)
@@ -225,15 +234,43 @@ class _InteractiveActivityMiddleware(_BaseHTTPMiddleware):
return await call_next(request)
# Heavy image ops that can run 30-150 s; a deploy restart must not cut these off.
_HEAVY_IMAGE_OP_PREFIXES = (
"/api/image/inpaint",
"/api/image/replace-background",
"/api/image/outpaint",
"/api/image/extend",
"/api/image/remove-bg",
"/api/image/mask",
"/api/image/upscale-local",
"/api/image/denoise",
"/api/image/enhance-face",
"/api/gallery/ai-upscale",
"/api/gallery/style-transfer",
"/api/editor/agent",
)
class _SlowRequestLogMiddleware(_BaseHTTPMiddleware):
async def dispatch(self, request, call_next):
from src import activity
path = request.url.path or ""
heavy = any(path.startswith(p) for p in _HEAVY_IMAGE_OP_PREFIXES)
if heavy:
activity.begin_image_op(path)
start = time.perf_counter()
status = 500
try:
response = await call_next(request)
status = getattr(response, "status_code", 0) or 0
return response
except _STREAM_CLIENT_EXC:
# Client disconnected / SSE stream ended — not a server error.
status = 499
return _JSONResponse({"detail": "client disconnected"}, status_code=499)
finally:
if heavy:
activity.end_image_op(path)
elapsed = time.perf_counter() - start
try:
threshold = float(os.getenv("ODYSSEUS_SLOW_REQUEST_LOG_SECONDS", "0.75") or "0.75")
@@ -822,6 +859,10 @@ app.include_router(setup_gallery_routes())
from routes.editor_draft_routes import setup_editor_draft_routes
app.include_router(setup_editor_draft_routes())
# Natural-language editor planning (planner wired to the configured chat model)
from routes.editor_agent_routes import setup_editor_agent_routes
app.include_router(setup_editor_agent_routes())
# Scheduled tasks + event bus
from src.task_scheduler import TaskScheduler
task_scheduler = TaskScheduler(session_manager)
@@ -1000,6 +1041,16 @@ async def get_version():
"source_commit": APP_SOURCE_COMMIT,
}
@app.get("/api/busy")
async def get_busy():
"""Report in-flight heavy work so a deploy can wait instead of dropping it."""
from src import activity
return {
"busy_image_ops": activity.heavy_image_ops_active(),
**activity.activity(),
}
@app.get("/api/health")
async def health_check() -> Dict[str, str]:
return {"status": "healthy", "timestamp": datetime.now(timezone.utc).isoformat()}
@@ -1438,4 +1489,35 @@ if __name__ == "__main__":
bind_host = os.getenv("APP_BIND", "127.0.0.1")
bind_port = int(os.getenv("APP_PORT", "7011"))
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
# Give in-flight requests time to finish on SIGTERM instead of being cut
# off mid-flight. Diffusion edits run 60-120 s; outpaint/extend can run up
# to ~330 s (provider cap 900 s), so default the graceful window to 900 s.
# systemd's TimeoutStopSec must exceed this (see the graceful-stop drop-in).
#
# We hook uvicorn's signal handler so a SIGTERM also flips src.shutdown:
# SSE / streaming generators check it and end promptly, so an open browser
# stream cannot stall a restart for the whole window. The long window stays
# a safety net for genuine in-flight image ops.
from src import shutdown as _shutdown
config = uvicorn.Config(
app,
host=bind_host,
port=bind_port,
log_level="info",
timeout_graceful_shutdown=int(os.getenv("ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT", "900") or 900),
)
server = uvicorn.Server(config)
# uvicorn's capture_signals() wires SIGINT/SIGTERM to self.handle_exit, so
# wrapping that method lets us set the shutdown flag before the normal exit
# path runs (this uvicorn version has no install_signal_handlers kwarg).
_uvicorn_handle_exit = server.handle_exit
def _handle_shutdown_signal(signum, frame):
_shutdown.request_shutdown()
_uvicorn_handle_exit(signum, frame)
server.handle_exit = _handle_shutdown_signal
server.run()
+39 -1
View File
@@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
# backwards for this sentinel.
ADMIN_PRIVILEGES["block_all_models"] = False
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH, USERNAME_MAX_LENGTH
from src.owner_identity import RESERVED_AUTH_USERNAMES
DEFAULT_AUTH_PATH = AUTH_FILE
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
@@ -62,6 +62,24 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
def username_error(username: str | None) -> Optional[str]:
"""Why a new username is not acceptable, or None when it is.
Admin routes carry the name as a URL path segment, where "/" never
reaches the handler, so such an account could not be managed afterwards.
"""
name = (username or "").strip()
if not name:
return "Username is required"
if len(name) > USERNAME_MAX_LENGTH:
return f"Username must be at most {USERNAME_MAX_LENGTH} characters"
if "/" in name or "\\" in name or any(not c.isprintable() for c in name):
return "Username must not contain slashes or control characters"
if name in (".", ".."):
return "Username is not allowed"
return None
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
"""Return a normalized username only when it exists in the auth user map."""
key = str(username or "").strip().lower()
@@ -396,6 +414,15 @@ class AuthManager:
current = self.get_privileges(username)
for k, v in privileges.items():
if k in DEFAULT_PRIVILEGES:
default = DEFAULT_PRIVILEGES[k]
if isinstance(default, bool):
ok = isinstance(v, bool)
elif isinstance(default, int):
ok = isinstance(v, int) and not isinstance(v, bool)
else:
ok = isinstance(v, list)
if not ok:
raise ValueError(f"Invalid value for privilege '{k}'")
current[k] = v
self._config["users"][username]["privileges"] = current
self._save()
@@ -436,6 +463,17 @@ class AuthManager:
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
if admin_count <= 1:
return SetAdminResult.LAST_ADMIN
# Token creation is admin-only and a demoted user can neither
# list nor revoke their tokens, so purge them with the role.
# Done before the flag flips: if the token store fails the
# exception propagates and the user is still an admin.
from core.database import get_db_session, ApiToken
with get_db_session() as db:
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
if removed_tokens:
logger.info(
f"Revoked {removed_tokens} API token(s) owned by demoted admin '{username}'"
)
# Write order matters for lock-free readers: get_privileges()
# reads without _config_lock and trusts is_admin, so the admin
# flag must be flipped while the stored map is safe to expose —
+22
View File
@@ -568,6 +568,7 @@ class ModelEndpoint(TimestampMixin, Base):
hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing
cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list)
pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models)
cookbook_serve_session_id = Column(String, nullable=True) # Current Cookbook serve identity
model_type = Column(String, nullable=True, default="llm") # "llm" or "image"
# auto = classify by URL; local = self-hosted server; api/proxy = external
# OpenAI-compatible API even when reachable through a private/tailnet IP.
@@ -1378,6 +1379,26 @@ def _migrate_add_pinned_models_column():
except Exception:
pass
def _migrate_add_cookbook_serve_session_id_column():
"""Bind Cookbook cleanup to the serve session that owns the endpoint."""
from sqlalchemy import inspect
with engine.begin() as conn:
columns = {
column["name"]
for column in inspect(conn).get_columns("model_endpoints")
}
if "cookbook_serve_session_id" not in columns:
conn.execute(
text(
"ALTER TABLE model_endpoints "
"ADD COLUMN cookbook_serve_session_id VARCHAR"
)
)
def _migrate_add_notes_sort_order():
"""Add sort_order, image_url, repeat columns to notes if they don't exist."""
import sqlite3
@@ -2390,6 +2411,7 @@ def init_db():
_migrate_add_hidden_models_column()
_migrate_add_cached_models_column()
_migrate_add_pinned_models_column()
_migrate_add_cookbook_serve_session_id_column()
_migrate_add_notes_sort_order()
_migrate_add_model_type_column()
_migrate_add_model_endpoint_refresh_columns()
+6 -2
View File
@@ -73,6 +73,8 @@ services:
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
@@ -82,13 +84,15 @@ services:
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
# Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
@@ -165,7 +169,7 @@ services:
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
+6 -2
View File
@@ -72,6 +72,8 @@ services:
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
@@ -81,13 +83,15 @@ services:
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
# Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
@@ -168,7 +172,7 @@ services:
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
+6 -2
View File
@@ -61,6 +61,8 @@ services:
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
@@ -70,13 +72,15 @@ services:
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
# Host workspace translation is opt-in. Keep the public compose file
# user-neutral; configure these in a local .env or use the host-workspace
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
@@ -146,7 +150,7 @@ services:
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
exec /usr/local/searxng/entrypoint.sh
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
+33 -11
View File
@@ -31,21 +31,43 @@ for spec in $SPECS; do
name="${spec%%==*}"
ver="${spec##*==}"
# pip download builds metadata (and trips the same bug), so fetch the raw
# sdist URL from the PyPI JSON API instead.
url="$(python - "$name" "$ver" <<'PY'
import json, sys, urllib.request
name, ver = sys.argv[1], sys.argv[2]
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
for f in data["urls"]:
if f["packagetype"] == "sdist":
print(f["url"]); break
# sdist URL from the PyPI JSON API instead. Use curl with retries to
# survive transient TLS / network failures that urllib cannot recover from.
metadata="${name}-${ver}.json"
curl --fail --silent --show-error --location \
--retry 8 \
--retry-delay 3 \
--retry-max-time 120 \
--retry-all-errors \
"https://pypi.org/pypi/${name}/${ver}/json" \
-o "$metadata"
url="$(python - "$metadata" <<'PY'
import json, sys
with open(sys.argv[1], encoding="utf-8") as f:
data = json.load(f)
for item in data["urls"]:
if item["packagetype"] == "sdist":
print(item["url"])
break
else:
sys.exit(f"no sdist found for {name}=={ver}")
raise SystemExit("no sdist found")
PY
)"
)"
rm -f "$metadata"
echo ">> fetching ${name} ${ver}: ${url}"
curl -fsSL "$url" -o "${name}.tar.gz"
curl --fail --silent --show-error --location \
--retry 8 \
--retry-delay 3 \
--retry-max-time 120 \
--retry-all-errors \
"$url" \
-o "${name}.tar.gz"
tar xzf "${name}.tar.gz"
rm -f "${name}.tar.gz"
done
echo ">> patching get_version()"
+208
View File
@@ -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.
+255
View File
@@ -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

+2 -1
View File
@@ -9,6 +9,7 @@ This handbook records the project's maintainer governance and gives reviewers a
| Area | Working rule |
|---|---|
| Product direction | Pewds is the Product Owner |
| New feature | Feature Request issue; big, new-direction or hard-to-undo features need a recorded Product Owner decision ([workflow](product-and-features.md#feature-proposal-workflow)) |
| Ordinary, reversible maintainer PR | Self-merge allowed after appropriate validation and applicable repository checks |
| External contribution | Maintainer review required |
| High-risk change | At least one **independent, qualified** maintainer review |
@@ -22,7 +23,7 @@ These are governance rules, not guarantees that a GitHub permission or branch-pr
## Start here
- [Roles and access](roles-and-access.md): responsibility, permissions, and continuity.
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership.
- [Product and feature proposals](product-and-features.md): product approval versus technical ownership, and the feature proposal workflow.
- [Changes and review](changes-and-review.md): review thresholds, self-merge, contributor corrections.
- [Practical review playbook](review-playbook.md): risk, provenance, CI, CodeQL, independent audit, and handoff evidence.
- [Triage](triage.md): turning incoming reports into actionable work.
+167 -13
View File
@@ -2,13 +2,15 @@
Pewds ([@pewdiepie-archdaemon](https://github.com/pewdiepie-archdaemon)) is the Product Owner for Odysseus and has final authority over product direction. Maintainers own technical feasibility, implementation, and safety.
This page has two parts: **which changes need product approval**, and **the workflow every feature proposal follows**, from issue to merged PR. The terms in bold are defined in [Definitions](#definitions); when a case is unclear, the definitions decide, not intuition.
## Which changes require product approval?
**Product Owner approval before implementation:** substantial new features, changes to product direction, and product changes that are hard to reverse.
**Product Owner approval before implementation:** features that are **big**, set a **new direction**, or are **hard to undo**.
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and small, reversible product changes within existing direction.
**No separate product approval by default:** bug fixes, security fixes, maintenance, tests, CI, documentation, internal refactors, and features that are **small**, **reversible**, and **fit ROADMAP**.
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer owns safe rollback or recovery.
Maintainers may make those smaller product decisions. Pewds can ask for changes or a rollback; the maintainer who decided owns the rollback or recovery.
### Urgent security and reliability fixes
@@ -25,19 +27,171 @@ Product Owner proposal or implementation
A Product Owner proposal is already product-approved; it is not automatically technically approved or exempt from high-risk review.
## Community or maintainer initiated
## Feature proposal workflow
Community proposals start as a Feature Request issue. Check [ROADMAP.md](../../ROADMAP.md) and existing issues first; take early ideas to [Discussions](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas).
Every community or maintainer feature proposal follows these steps. The issue is the record at every step: labels show where a proposal stands, and comments hold the decisions.
```text
Feature proposal
-> Product Owner accepts / rejects, where approval is required
-> technical feasibility
-> plan and implementation
-> risk-based review and validation
```mermaid
flowchart TD
A["0. Idea (optional)<br/>Discussions › Ideas"] -.-> B["1. Feature Request issue"]
B --> C{"2. Triage<br/>within 7 days"}
C -- "duplicate / not actionable" --> X["Closed with a reason"]
C -- "small + reversible + fits ROADMAP" --> M["product: maintainer decision"]
C -- "big, new direction or hard to undo" --> N["product: needs decision"]
N --> D["3. Decision brief"]
D --> E{"4. Product Owner decides"}
E -- yes --> P["product: approved"]
E -- no --> R["product: declined<br/>closed as not planned"]
E -- "no answer in 14 days" --> L{"Only reason was size?"}
L -- yes --> M
L -- "no: new direction or hard to undo" --> N
M --> F["5. Implementation PRs<br/>Part of #N"]
P --> F
F --> G["6. Review and merge"]
G --> H["7. Close the issue"]
```
Record the decision in an issue, proposal, work item, or PR so future maintainers can discover it. Do not rely on a chat-only decision.
### Step 0 — Idea (optional)
Half-formed ideas go to [Discussions › Ideas](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) to gauge interest. Nothing is decided there. A proposal is ready for step 1 when it is **actionable**.
### Step 1 — Feature Request issue
**Who:** the proposer.
Open a [Feature Request](https://github.com/odysseus-dev/odysseus/issues/new?template=feature_request.yml) after checking [ROADMAP.md](../../ROADMAP.md) and open issues. Besides the required fields, the form asks three optional questions that make triage faster:
- whether the proposer thinks it is big, a new direction, or hard to undo;
- the yes/no questions they want the Product Owner to answer;
- screenshots, a recording, or a demo branch.
A proposal with several independent parts may be one issue, provided each part is listed separately so it can be decided separately.
### Step 2 — Triage
**Who:** a **triager**. **When:** within 7 days of the issue being opened.
The triager does one of three things and records why in a comment:
| Outcome | When | Label | Next |
|---|---|---|---|
| Close | **Duplicate** or not **actionable** | — (closed as duplicate or not planned, with a link) | — |
| Maintainer decision | **Small** *and* **reversible** *and* **fits ROADMAP** | `product: maintainer decision` | Step 5 |
| Needs the Product Owner | **Big**, *or* **new direction**, *or* **hard to undo** | `product: needs decision` | Step 3 |
The triage comment names the ROADMAP line the proposal fits, or says that it fits none, and which of big / new direction / hard to undo applies. When the triager is unsure, the proposal goes to the Product Owner.
### Step 3 — Decision brief
**Who:** the proposer writes it; the triager checks it is complete before the clock starts.
The brief goes at the top of the issue body (edit the issue, or post it as the first comment and link it from the body) and contains:
1. **Summary:** what changes for users, in three to five lines.
2. **Questions:** the exact yes/no questions, one per independent part.
3. **Evidence:** screenshots or a recording for anything that renders; a demo branch if one exists.
4. **What cannot be undone:** migrations, deleted data, changed contracts, or "nothing".
5. **For big designs only:** architecture, migration and rollback, and the planned PR slices. A long design may live in a proposal document under `/design` and be linked from the brief.
When the brief is complete, the triager mentions the Product Owner on the issue. This is **day 0** for the timeout.
### Step 4 — Product Owner decision
**Who:** the Product Owner. **When:** on the issue at any time, or at the next **queue review**.
| Answer | Label | Recorded as |
|---|---|---|
| Yes, possibly with conditions | `product: approved` | A **recorded decision** listing any conditions |
| No | `product: declined` | A recorded decision; the issue is closed as not planned |
| A question | stays `product: needs decision` | The clock pauses until the proposer answers, then restarts at day 0 |
| Different answers per part | the label for the approved part(s) | The recorded decision lists each part and its answer |
A decision given outside GitHub (Matrix, a call) counts once a maintainer has recorded it on the issue. A declined feature can be maintained in a contributor's fork, and the issue can be reopened only with **materially new** information.
#### When there is no answer: lazy consensus
| Day | What happens |
|---|---|
| 0 | Brief complete; Product Owner mentioned on the issue |
| 7 | One reminder from a maintainer, and the issue is put on the next queue review |
| 14 | If there has been no **objection**: |
- If the **only** reason the proposal needed the Product Owner was that it is **big** — it is **reversible** and **fits ROADMAP** — a maintainer may decide. They record "decided by lazy consensus on YYYY-MM-DD", apply `product: maintainer decision`, and own any rollback Pewds later asks for.
- If it is a **new direction** or **hard to undo**, it keeps waiting. Silence never approves a one-way change or a change of direction. It is raised at each queue review.
- A proposal still waiting after **90 days** is closed as not planned with a comment saying it expired without a decision. It can be reopened by anyone with the same brief.
### Step 5 — Implementation
**Who:** the proposer or any contributor.
Feature PRs link the issue with `Part of #N` (or `Fixes #N` for the last slice) and stay within what was decided. A maintainer does not merge a feature PR whose issue carries neither `product: approved` nor `product: maintainer decision`, and checks any conditions in the recorded decision.
If the work grows beyond what was decided — a new part, or a part that becomes hard to undo — that part goes back to step 2.
A maintainer's own small, reversible feature that fits ROADMAP may skip the separate issue: the PR description states why it qualifies, and the PR is the record.
### Step 6 — Review and merge
Unchanged: [changes and review](changes-and-review.md). Product approval is not technical approval; high-risk changes still need an independent qualified review.
### Step 7 — Close
The issue is closed when the last PR lands. If the feature was a ROADMAP item, the same PR or a follow-up updates ROADMAP.md.
## Labels
| Label | Meaning | Applied by |
|---|---|---|
| `product: needs decision` | Waiting for the Product Owner | Triager |
| `product: approved` | The Product Owner said yes (see the recorded decision for scope and conditions) | Product Owner or the maintainer recording the decision |
| `product: maintainer decision` | Small, reversible and on ROADMAP, or decided by lazy consensus | Triager, or the maintainer applying lazy consensus |
| `product: declined` | The Product Owner said no | Product Owner or the maintainer recording the decision |
Only maintainers and the Product Owner set or change these labels. Exactly one of them is on a triaged feature issue at any time.
## Definitions
**Feature.** A change that adds or alters user-visible behavior, a setting, or a public contract (HTTP API, MCP tool, plugin interface, file format). Restoring documented or clearly intended behavior is a bug fix, not a feature.
**Small.** All of the following are true:
- it ships as one PR of ordinary size — as a guide, no more than about 600 changed lines excluding tests, generated files and lockfiles;
- it touches one area of the app;
- it adds an option or a capability without changing what existing users see or get by default;
- it adds no new dependency, service, or background process.
Anything else is **big**.
**Reversible.** Reverting the PR(s) fully restores the previous state for every user, with nothing left behind: no data migrated, rewritten or deleted; no stored format that other code or users now depend on; no public contract that integrations have started to use; no security boundary moved. If any of those is not true, the change is **hard to undo**.
**Hard to undo.** Not reversible. Typical cases: data migrations, deleting or purging data, new persisted formats, new or changed authentication, permission or sharing models, and removing or renaming a public API, MCP tool, setting or file location.
**New direction.** The change takes Odysseus somewhere it has not committed to. Any of:
- a new top-level area of the app (a new panel, tab or module);
- multi-user or collaboration features where the app was single-user;
- data or requests leaving the user's machine by default, including telemetry and new external services;
- dropping or adding a supported platform, install method or model backend;
- anything listed under "Not The Focus Right Now" in ROADMAP.md;
- reversing a previous Product Owner decision.
**Fits ROADMAP.** The triager can point to a specific ROADMAP.md item that the proposal implements or directly extends, and the proposal is not listed under "Not The Focus Right Now". "It is in the same area as an item" is not enough.
**Actionable.** The issue states a concrete problem and a concrete change in behavior, specific enough to write acceptance criteria from.
**Duplicate.** Same user-facing outcome as an open issue or open PR, or a request for a ROADMAP item that adds nothing to it. Close it with a link, and move any new information to the original. A proposal that offers to *implement* a ROADMAP item, with a concrete design, is not a duplicate: it is triaged normally and usually **fits ROADMAP**.
**Triager.** Any maintainer other than the proposer. A maintainer's own proposal is triaged by another maintainer.
**Objection.** A comment on the issue — or one recorded there from another channel — by the Product Owner or a maintainer, asking to stop, wait, or change the scope. A maintainer's objection stops lazy consensus for that proposal; it then waits for the Product Owner like a new direction does. Questions and suggestions that do not ask to stop are not objections.
**Recorded decision.** A comment on the issue that states the decision, who made it, the date, where it was made if not on GitHub, the parts it covers, and any conditions — together with the matching label change.
**Queue review.** Every 14 days, a maintainer brings the [`product: needs decision` list](https://github.com/odysseus-dev/odysseus/issues?q=is%3Aissue+is%3Aopen+label%3A%22product%3A+needs+decision%22) to the Product Owner and records each answer on its issue. The maintainer who runs it is agreed among maintainers and noted here: *to be decided*.
**Days.** Calendar days, counted from the timestamp of the event on the issue (label applied, brief completed, reminder posted).
**Materially new.** Information the original decision did not have: a different design, new evidence of demand, or a changed ROADMAP.
## Decision boundaries
@@ -45,4 +199,4 @@ The Product Owner decides **what belongs in Odysseus**. Maintainers decide **how
A rejected or out-of-scope feature can be maintained independently in a contributor's fork.
If the Product Owner is unavailable, work requiring prior product approval waits unless authority was explicitly delegated. Smaller decisions and urgent fixes follow the rules above.
If the Product Owner is unavailable, work that needs product approval follows the timeout above. It never proceeds on silence when it is a new direction or hard to undo. Smaller decisions and urgent fixes follow the rules above.
+2
View File
@@ -26,6 +26,8 @@ Record what is known and distinguish it from what remains unverified:
- Priority, ownership, and relevant domain expertise.
- Remaining unknowns, risks, and safe recovery or workaround when available.
Feature requests follow the [feature proposal workflow](product-and-features.md#feature-proposal-workflow): triage within 7 days, and a product label on every triaged feature issue.
Small and obvious fixes may go straight to a focused PR, consistent with [CONTRIBUTING.md](../../CONTRIBUTING.md) and [changes and review](changes-and-review.md).
## Practical priority language
@@ -112,7 +112,7 @@ export function createCardInteractions({
if (state.selectMode) {
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('.note-card-cb')) return; // checkbox handles itself
if (e.target.closest('.note-card-cb, a')) return; // links open without toggling selection
e.stopPropagation();
tapToEditOrSelect(card);
});
+2 -2
View File
@@ -51,7 +51,7 @@ export function createEditor({
<input type="hidden" class="note-form-due" value="${note?.due_date || ''}" />
<input type="hidden" class="note-form-repeat" value="${note?.repeat || 'none'}" />
</div>
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button></div>` : ''}
${currentImageUrl && type !== 'draw' ? `<div class="note-form-image-wrap"><img class="note-form-image" src="${_esc(currentImageUrl)}" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button></div>` : ''}
<div class="note-form-body">
${type === 'note'
? `<textarea class="note-form-content" placeholder="Take a note..." rows="4">${_esc(note?.content || '')}</textarea>`
@@ -297,7 +297,7 @@ export function createEditor({
form.querySelector('.note-form-image-wrap')?.remove();
const wrap = document.createElement('div');
wrap.className = 'note-form-image-wrap';
wrap.innerHTML = `<img class="note-form-image" draggable="false" /><button class="note-form-image-rm" title="Remove">&times;</button>`;
wrap.innerHTML = `<img class="note-form-image" alt="" draggable="false" /><button class="note-form-image-rm" title="Remove" aria-label="Remove">&times;</button>`;
// Insert AFTER the whole header (a flex-row), not after the
// title input itself : otherwise the image lands as a sibling
// of the title inside the header and flex puts them side-by-side.
+1 -1
View File
@@ -31,7 +31,7 @@ export function createText({
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
+28 -18
View File
@@ -33,6 +33,7 @@ from mcp.types import Tool, TextContent
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.email_mime import decode_text_part as _decode_text_part
server = Server("email")
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
@@ -923,29 +924,25 @@ def _extract_text(msg):
"""Extract plain text body from email message."""
if msg.is_multipart():
text_parts = []
html_parts = []
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/plain" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
text_parts.append(payload.decode(charset, errors="replace"))
elif ct == "text/html" and not text_parts and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
raw_html = payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
text_parts.append(text)
elif ct == "text/html" and not html_parts and "attachment" not in cd:
raw_html = _decode_text_part(part)
if raw_html:
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text_parts.append(text.strip())
return "\n".join(text_parts)
html_parts.append(text.strip())
return "\n".join(text_parts or html_parts)
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
if msg.get_content_type() == "text/html":
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
@@ -1185,6 +1182,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
"subject": subject,
"from": sender_name or sender_addr or sender,
"from_address": sender_addr,
"to": str(row.get("to") or headers.get("To") or ""),
"cc": str(row.get("cc") or headers.get("Cc") or ""),
"references": str(row.get("references") or headers.get("References") or ""),
"date": date_str,
"date_epoch": date_epoch,
"summary": body[:240],
@@ -1557,6 +1557,7 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
if not isinstance(rows, list):
return False
owner = _current_owner()
matches = []
for index, row in enumerate(rows, start=1):
if not isinstance(row, dict):
continue
@@ -1568,6 +1569,12 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
continue
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
continue
if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
continue
matches.append(row)
if len(matches) != 1:
return False
row = matches[0]
for key, value in updates.items():
if value is None:
row.pop(key, None)
@@ -1575,7 +1582,6 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
row[key] = value
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return True
return False
# ── Tool implementations ──
@@ -2925,7 +2931,9 @@ def _create_email_draft_document(
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
"""Create a threaded Odysseus reply draft document. Does not send."""
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account)
fixture = _fixture_read_email(uid=uid, folder=folder, account=account)
if _fixture_email_enabled() and (fixture is None or fixture.get("error")):
return fixture or {"error": "No email fixture is available for the authenticated owner"}
if fixture is not None:
sender = str(fixture.get("from_address") or fixture.get("from") or "")
_, sender_addr = email.utils.parseaddr(sender)
@@ -3178,7 +3186,9 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
def _set_flag(uid, folder, flag, add=True, account=None):
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
if _fixture_email_enabled():
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is None:
return False
if flag == "\\Seen":
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
if flag == "\\Answered":
+2 -2
View File
@@ -33,7 +33,7 @@ async def list_tools() -> list[Tool]:
"prompt": {"type": "string", "description": "Image description prompt"},
"model": {"type": "string", "description": "Model name (auto-detects if omitted)"},
"size": {"type": "string", "description": "Image size (default 1024x1024)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default medium)"},
"quality": {"type": "string", "description": "Quality: low, medium, high, auto (default configured quality, otherwise medium)"},
},
"required": ["prompt"],
},
@@ -66,7 +66,7 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
if "quality" not in arguments and _settings.get("image_quality"):
quality = _settings["image_quality"]
# Auto-detect best available image model
+5 -1
View File
@@ -9,7 +9,11 @@
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:install": "playwright install chromium firefox webkit",
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:install": "playwright install chromium webkit"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.7.0",
+1
View File
@@ -2,3 +2,4 @@
-r requirements.txt
# psutil lets `-n auto` use physical cores instead of logical CPU threads.
pytest-xdist[psutil]>=3.8,<4
playwright==1.62.0
+54 -7
View File
@@ -13,11 +13,12 @@ import re
from pathlib import Path
from core.atomic_io import atomic_write_json, atomic_write_text
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL, username_error
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
from src.auth_helpers import _auth_disabled
from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings
from src import tool_capabilities
from src.settings import (
load_settings as _load_settings,
save_settings as _save_settings,
@@ -154,8 +155,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(400, "Already configured")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
@@ -174,8 +176,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
@@ -333,8 +336,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
raise HTTPException(403, "Admin only")
if len(body.password) < PASSWORD_MIN_LENGTH:
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
if len(body.username.strip()) < 1:
raise HTTPException(400, "Username is required")
name_error = username_error(body.username)
if name_error:
raise HTTPException(400, name_error)
if body.username.lower() in RESERVED_USERNAMES:
raise HTTPException(403, "Username is reserved")
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
@@ -361,8 +365,16 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
try:
body = await request.json()
except ValueError:
raise HTTPException(400, "Request body must be a JSON object")
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
try:
ok = auth_manager.set_privileges(username, body)
except ValueError as e:
raise HTTPException(400, str(e))
if not ok:
raise HTTPException(404, "User not found or is admin")
return {"ok": True, "privileges": auth_manager.get_privileges(username)}
@@ -376,6 +388,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
new_username = (body.username or "").strip().lower()
if not new_username:
raise HTTPException(400, "Username required")
name_error = username_error(new_username)
if name_error:
raise HTTPException(400, name_error)
if old_username == new_username:
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
if old_username not in auth_manager.users:
@@ -653,7 +668,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
if not user or not auth_manager.is_admin(user):
raise HTTPException(403, "Admin only")
try:
result = auth_manager.set_admin(username, body.is_admin, user)
finally:
# A demotion purges the user's ApiToken rows; dirty the bearer
# cache so already-cached tokens stop authenticating.
if not body.is_admin:
invalidator = getattr(
getattr(getattr(request, "app", None), "state", None),
"invalidate_token_cache", None,
)
if callable(invalidator):
invalidator()
if result is SetAdminResult.USER_NOT_FOUND:
raise HTTPException(404, "User not found")
if result is SetAdminResult.NOT_AUTHORIZED:
@@ -753,6 +779,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
settings = without_retired_settings(_load_settings())
if _auth_disabled() or (user and auth_manager.is_admin(user)):
# Deployment-level truth for the Tool approval row: a falsy
# ODYSSEUS_TOOL_APPROVAL_GATE disables the approval gate for every
# run regardless of the stored tool_approval_mode, so surface it to
# admins instead of letting the row mislead them the other way.
settings["tool_approval_gate_env_disabled"] = not tool_capabilities.TOOL_APPROVAL_GATE_ENABLED
return settings
return scrub_settings(settings)
@@ -769,6 +800,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
"auto_compact_threshold_percent": (50, 95),
}
_VALID_VALUES = {
"tool_approval_mode": {"ask", "off"},
}
for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS:
continue
@@ -782,6 +816,15 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
except (TypeError, ValueError):
raise HTTPException(400, f"{key} must be an integer")
val = max(lo, min(val, hi))
if key in _VALID_VALUES:
allowed = _VALID_VALUES[key]
if not isinstance(val, str) or val not in allowed:
raise HTTPException(
400,
f"{key} must be one of {sorted(allowed)}",
)
current[key] = val
_save_settings(current)
return without_retired_settings(current)
@@ -887,6 +930,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Create a new integration (admin only)."""
_require_admin_user(request)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = add_integration(body)
return {"ok": True, "integration": mask_integration_secret(item)}
@@ -895,6 +940,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"""Update an existing integration (admin only)."""
_require_admin_user(request)
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
item = update_integration(integration_id, body)
if not item:
raise HTTPException(404, "Integration not found")
+16 -16
View File
@@ -80,6 +80,18 @@ def _require_user(request: Request) -> str:
return FALLBACK_OWNER
async def _json_object_body(request: Request) -> dict:
"""Parse a JSON object body. An unreadable body counts as empty; a body
that is valid JSON but not an object (a list, string or number) is a 400."""
try:
body = await request.json()
except Exception:
return {}
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
return body
def _get_or_404_calendar(db, cal_id: str, owner: str) -> CalendarCal:
cal = db.query(CalendarCal).filter(CalendarCal.id == cal_id).first()
if not cal:
@@ -994,10 +1006,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def save_config(request: Request):
"""Legacy single-account endpoint — upserts the first account."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
accounts = _get_caldav_accounts(owner)
if not (body.get("url") or "").strip():
_save_caldav_accounts(owner, [])
@@ -1052,10 +1061,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
"""Add a new CalDAV account."""
import uuid as _uuid
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
from src.caldav_sync import validate_caldav_url
try:
url = validate_caldav_url(body.get("url", ""))
@@ -1080,10 +1086,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
async def update_caldav_account(account_id: str, request: Request):
"""Update an existing CalDAV account by id."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
accounts = _get_caldav_accounts(owner)
idx = next((i for i, a in enumerate(accounts) if a.get("id") == account_id), None)
if idx is None:
@@ -1124,10 +1127,7 @@ def setup_calendar_routes(upload_handler=None) -> APIRouter:
test an already-saved account. Falls back to the first saved account
when nothing is provided."""
owner = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
body = await _json_object_body(request)
url = (body.get("url") or "").strip()
user = (body.get("username") or "").strip()
pw = body.get("password") or ""
+119 -8
View File
@@ -569,8 +569,72 @@ async def auto_name_session(session_manager, sess):
if not first_msg:
return
from src.owner_identity import is_request_sentinel_owner
owner = getattr(sess, "owner", None)
invalid_owner = (
not isinstance(owner, str) or not owner.strip()
or owner != owner.strip() or is_request_sentinel_owner(owner)
)
if invalid_owner:
session_manager.update_session_name(sess.id, fallback_session_title(first_msg))
return
endpoint_url = str(getattr(sess, "endpoint_url", "") or "")
model_name = str(getattr(sess, "model", "") or "")
# The spec ("Ajax") model names its own chats from the user's first
# prompt. This function only runs after the first response stream has
# settled (auto_name_session_after_stream), so titling does not
# contend with the visible answer. Thinking is disabled and the
# budget kept small so the model returns a bare title.
try:
from src.model_profiles import uses_odysseus_progressive_thinking
_is_spec_model = bool(model_name) and uses_odysseus_progressive_thinking(model_name)
except Exception:
_is_spec_model = False
if _is_spec_model and endpoint_url:
try:
from src.database import SessionLocal
from src.endpoint_resolver import (build_chat_url, build_headers,
resolve_endpoint_runtime, resolve_owner_registered_endpoint)
from src.owner_identity import auth_disabled
owner = getattr(sess, "owner", None)
if not owner and not auth_disabled():
raise ValueError("Session has no owner")
db = SessionLocal()
try:
endpoint = resolve_owner_registered_endpoint(db, endpoint_url, owner)
base, key = resolve_endpoint_runtime(endpoint, owner=owner)
title_url = build_chat_url(base)
title_headers = build_headers(key, base)
finally:
db.close()
title = await llm_call_async(
title_url,
model_name,
[
{"role": "system", "content": "Generate a short title (3-6 words, no quotes) for a conversation that starts with this message. Reply with ONLY the title, nothing else. Do NOT include any thinking, reasoning, or explanation \u2014 just the title."},
{"role": "user", "content": first_msg},
],
temperature=0.3,
max_tokens=32,
headers=title_headers,
timeout=20,
thinking_mode="off",
)
except Exception as e:
logger.warning("[auto-name] spec title call failed for %s: %s", sess.id, e)
title = ""
title = str(title or "").strip().strip('"\'').strip()
from src.text_helpers import strip_think
title = strip_think(title, prose=False, prompt_echo=False)
if not title or len(title) >= 80 or "\n" in title:
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named spec session {sess.id}: {title}")
return
if (
"ttft" in model_name.lower()
or re.search(r":18\d{3}\b", endpoint_url)
@@ -581,6 +645,14 @@ async def auto_name_session(session_manager, sess):
return
owner = getattr(sess, "owner", None)
from src.owner_identity import auth_disabled
if not owner and not auth_disabled():
title = fallback_session_title(first_msg)
session_manager.update_session_name(sess.id, title)
logger.info(f"Auto-named unowned session {sess.id} deterministically: {title}")
return
from src.task_endpoint import resolve_task_endpoint
t_url, t_model, t_headers = resolve_task_endpoint(
sess.endpoint_url, sess.model, sess.headers, owner=owner
)
@@ -588,11 +660,47 @@ async def auto_name_session(session_manager, sess):
logger.debug("[auto-name] No model provided, skipping")
return
# max_tokens big enough that reasoning models (Minimax M2,
# DeepSeek R1, QwQ, etc.) have headroom for <think>…</think>
# plus the actual title — 200 used to clip them mid-reasoning
# so strip_think left an empty string and no rename happened.
# Timeout matches: 60s gives slow local reasoners room to finish.
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
# A configured effort is the user's explicit choice. Otherwise thinking
# stays off, because reasoning would use up this small token budget and
# leave no title.
title_effort = effort_for_call(t_url, t_model, owner, sess)
try:
title = await llm_call_async(
t_url,
t_model,
@@ -604,8 +712,12 @@ async def auto_name_session(session_manager, sess):
max_tokens=64,
headers=t_headers,
timeout=15,
reasoning_effort=effort_for_call(t_url, t_model, owner, sess),
reasoning_effort=title_effort,
thinking_mode=None if title_effort else "off",
)
except Exception as e:
logger.warning("[auto-name] non-spec title call failed for %s: %s", sess.id, e)
title = ""
title = title.strip().strip('"\'').strip()
# Strip <think>/<thinking> blocks (closed, dangling, or stray tags)
@@ -1164,6 +1276,7 @@ async def build_chat_context(
message=_ctx_msg,
session=sess,
use_web=use_web_val and not skip_web,
use_rag=use_rag_val,
use_memory=mem_enabled,
time_filter=time_filter,
preset_system_prompt=preset.system_prompt,
@@ -1174,8 +1287,6 @@ async def build_chat_context(
incognito=incognito,
use_skills=skills_enabled,
)
if use_rag is not None or is_research_spinoff or casual_low_signal:
_preface_kwargs["use_rag"] = use_rag_val
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
# Capture used memories immediately
+119 -12
View File
@@ -27,11 +27,13 @@ from src.llm_core import (
from src.agent_loop import (
stream_agent_loop,
_configured_model_tool_surface,
_configured_model_tool_surface_raw,
_local_media_needs_browser_render,
_looks_like_workspace_coding_request,
)
from src.agent_loop import _normalize_ody_qwen_text_artifacts
from src import agent_runs
from src import shutdown as _shutdown
from src.model_context import estimate_tokens
from src.context_compactor import (
apply_compaction_state,
@@ -329,6 +331,7 @@ def _is_external_discovery_request(text: str) -> bool:
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
_EDITOR_SESSION_RE = re.compile(r"[A-Za-z0-9@._:-]{1,128}")
def _mentions_pdf_url(value: str) -> bool:
@@ -1898,7 +1901,7 @@ def _ts_or_zero(value) -> float:
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
"""Re-populate sess.model from the matching endpoint's cached models.
"""Recover sess.model from the endpoint's visible cached and pinned models.
Covers the window between endpoint setup and the first chat send: the
picker showed a model in the dropdown but the session record never got
@@ -1962,6 +1965,9 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
except Exception:
visible = cached
if not is_chatgpt_subscription:
from src.endpoint_resolver import _endpoint_enabled_models
visible = _endpoint_enabled_models(ep)
if current_model and current_model in {str(item).strip() for item in visible}:
return False
if is_chatgpt_subscription:
@@ -2688,6 +2694,23 @@ def setup_chat_routes(
logger.info("chat→agent auto-escalation: explicit private browser workflow")
active_doc_id = form_data.get("active_doc_id", "").strip()
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
# Scoped photo_editor agent context: the Quick Edit popup (and any
# image-edit chat turn) may carry the open image-editor session id and a
# canvas snapshot so the editor tool family can drive it in-process.
# The id is quoted into a system message, so accept only an
# identifier; any other value is treated as no open editor.
editor_session = str(
form_data.get("editor_session")
or form_data.get("editor_session_id")
or ""
).strip()
if not _EDITOR_SESSION_RE.fullmatch(editor_session):
editor_session = ""
photo_editor_requested = (
str(form_data.get("photo_editor") or "").strip().lower()
in {"1", "true", "yes", "on"}
or bool(editor_session)
)
logger.info(
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
chat_mode, active_doc_id, active_doc_state,
@@ -3270,7 +3293,34 @@ def setup_chat_routes(
active_document=bool(active_doc), workspace=bool(workspace),
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
) if _use_turn_contract else frozenset()
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
# Scoped photo_editor family: offered ONLY for an explicit editor
# request (Quick Edit popup / editor session) or an image-edit chat
# turn carrying an image. When active the turn offers just the editor
# tools plus ask_user, never the general tool list.
_photo_editor_active = bool(_use_turn_contract) and (
photo_editor_requested
or (
"image_editing" in _turn_capabilities
and bool(ctx.preprocessed.attachment_meta)
)
)
if _photo_editor_active:
if editor_session:
editor_context = {'role': 'system', 'content':
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
'plan_edit and get_editor_screenshot to inspect and change the current canvas, and '
'ai_edit_image for model-based edits. Apply the user\'s requested edit to the open '
'editor and confirm the result with a screenshot.'}
else:
editor_context = {'role': 'system', 'content':
'The user attached an image but no image editor is open, so the editor tools cannot '
'reach the canvas. Do not call editor tools. Reply briefly telling the user to open '
'the attached image in the editor (Quick Edit) and repeat the request; the editor '
'tools will then apply it.'}
ctx.messages.insert(0, editor_context)
if foreground_policy.enabled:
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(editor_context))
elif 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
if image_refs:
@@ -3307,6 +3357,11 @@ def setup_chat_routes(
# pasted revision request. This only offers permitted schemas;
# it never requires or performs a document mutation.
_turn_capabilities = _turn_capabilities | {"documents"}
# Scoped photo_editor family (see above): when active the turn offers
# just the editor tools plus ask_user, never the general tool list.
if _photo_editor_active:
_turn_capabilities = frozenset({"photo_editor"})
_active_turn_capabilities = _turn_capabilities
# Same decision that prepared the turn; it only stamps the contract
# inside the agent-contract branch below.
_clean_v3_preview = _compact_preview_turn
@@ -3474,7 +3529,7 @@ def setup_chat_routes(
if not _privs.get("can_use_documents", True):
disabled_tools.update({"manage_documents", "create_document", "edit_document", "update_document", "suggest_document"})
if not _privs.get("can_generate_images", True):
disabled_tools.update({"generate_image", "edit_image"})
disabled_tools.update({"generate_image", "edit_image", "ai_edit_image"})
if not _privs.get("can_manage_memory", True):
disabled_tools.update({"manage_memory", "manage_skills"})
if not _privs.get("can_use_research", True):
@@ -3556,9 +3611,22 @@ def setup_chat_routes(
"document_editor": not {
"manage_documents", "create_document", "edit_document", "update_document",
}.issubset(disabled_tools),
# A photo-editing chat turn can only reach a canvas when the image
# editor is actually open. The compact runtime uses this to reply
# (open the image in the editor) instead of looping on editor tools
# that have no canvas to drive.
"photo_editor_open": bool(editor_session),
}
# Capture permission state before schema selection/reconciliation.
# Only deterministic request intent supplies grants, never inventory.
# The strict router (spec) surface picks its tools in src/spec_router.py
# AFTER authority is built, so its admission is keyed off the surface.
try:
_spec_surface = _configured_model_tool_surface_raw(
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
) == "spec"
except Exception:
_spec_surface = False
_request_authority = request_authority_for_http(
request, message, owner=_user, session_id=session, workspace=workspace,
history=_turn_history, policy=tool_policy,
@@ -3566,9 +3634,9 @@ def setup_chat_routes(
active_document=bool(active_doc),
image_attachment=any(str(a.get('mime') or '').startswith('image/')
for a in (ctx.preprocessed.attachment_meta or [])),
capabilities=({'search_browser'} if (
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
_explicit_browser_intent or _external_discovery_intent
) else ()),
) else set())),
)
if exact_tool_approval is not None:
from src.agent_runtime.authority import RequestAuthority
@@ -3758,7 +3826,9 @@ def setup_chat_routes(
# personal manager; core shell/Web tools are not task
# fallbacks. Other turns retain warm and workspace tools.
extra_tools=(
frozenset()
frozenset({"ask_user"})
if _active_turn_capabilities == frozenset({"photo_editor"})
else frozenset()
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
_active_turn_capabilities in (
frozenset({"transcription"}), frozenset({"ocr"}),
@@ -3856,6 +3926,19 @@ def setup_chat_routes(
if ctx.preprocessed.attachment_meta:
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
# Give the page the saved user message's id. Only the reply's id
# was sent back, so a message sent in the open page had none and
# a later regenerate/edit of it could not cut the chat by id.
try:
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
if getattr(m, "role", None) == "user"), None)
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
if _last_user_msg is not None else None)
if _user_db_id:
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
except Exception:
pass
# Announce any docs auto-created during preprocess (e.g. fillable
# PDF → editable markdown) so the editor pane switches to them
# before the model starts streaming.
@@ -3960,7 +4043,7 @@ def setup_chat_routes(
_heartbeat_counter = 0
_last_progress = {}
_sent_avg = False
while True:
while not _shutdown.is_shutting_down():
status = research_handler.get_status(session)
if not status or status["status"] != "running":
break
@@ -4222,8 +4305,6 @@ def setup_chat_routes(
# indicator, but don't fold them into the saved
# reply (mirrors the rewrite path below).
if data.get("thinking"):
if thinking_mode == "off":
continue
thinking_response += data["delta"]
else:
full_response += data["delta"]
@@ -4667,8 +4748,6 @@ def setup_chat_routes(
# Forward them for the live indicator, but keep
# them out of the saved reply (same as chat mode).
if data.get("thinking"):
if thinking_mode == "off":
continue
thinking_response += data["delta"]
else:
full_response = _render_state.content
@@ -4852,6 +4931,25 @@ def setup_chat_routes(
_terminal_saved = True
if _saved_id:
yield f'data: {json.dumps(_render_state.message_saved(_saved_id))}\n\n'
run_post_response_tasks(
sess, session_manager, session, message, _saveable_no_tool_response,
_metrics_to_save, ctx.uprefs, memory_manager, memory_vector, webhook_manager,
incognito=incognito, compare_mode=compare_mode,
character_name=ctx.preset.character_name,
agent_rounds=_agent_rounds,
agent_tool_calls=_agent_tool_calls,
skills_manager=skills_manager,
owner=_user,
extract_skills=(
user_requested_agent
and not tool_approval_continuation
),
allow_background_extraction=_post_response_extraction_allowed(
tools_blocked=tool_policy.block_all_tool_calls,
tool_approval_continuation=tool_approval_continuation,
client_runtime_context=client_runtime_context,
),
)
yield f'data: {json.dumps(_metrics_event)}\n\n'
except json.JSONDecodeError:
yield chunk
@@ -4962,6 +5060,8 @@ def setup_chat_routes(
raises before reaching a mode-specific finally block."""
try:
async for chunk in stream_with_save():
if _shutdown.is_shutting_down():
break
yield chunk
finally:
_active_streams.pop(session, None)
@@ -5056,7 +5156,12 @@ def setup_chat_routes(
if rec is None:
if agent_runs.is_active(session_id):
return {"status": "streaming", "detached": True}
raise HTTPException(404, "No active stream for this session")
# No active stream: report an explicit idle state with HTTP 200.
# A 404 here made every session load look like a failed request in
# the browser console. The client distinguishes "no stream" by the
# body (status != "streaming"), and still tolerates a 404 from an
# older server, so this stays backward compatible.
return {"status": "idle"}
return rec
# ------------------------------------------------------------------ #
@@ -5155,6 +5260,8 @@ def setup_chat_routes(
max_tokens=0,
tools=None,
):
if _shutdown.is_shutting_down():
break
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
data = json.loads(chunk[6:])
+1
View File
@@ -0,0 +1 @@
"""Cookbook route domain package (#4071)."""
+284
View File
@@ -0,0 +1,284 @@
"""Auto-register Cookbook-served models as model endpoints."""
import json
import logging
import uuid
from routes.cookbook_helpers import ServeRequest
logger = logging.getLogger(__name__)
def _auto_register_image_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server
port_match = re.search(r'--port\s+(\d+)', req.cmd)
port = int(port_match.group(1)) if port_match else 8100
# Determine host
if remote:
# SSH alias — use as hostname (Tailscale resolves it later)
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
# Friendly display name from repo_id
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = f"{short_name} (image)"
pinned_models = [req.repo_id] if req.repo_id else []
db = SessionLocal()
try:
# Check for existing endpoint with same base_url — update it
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "image"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual"
existing.cookbook_serve_session_id = serve_session_id
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models)
db.commit()
settings = load_settings()
if settings.get("image_gen_enabled") is not True:
settings["image_gen_enabled"] = True
save_settings(settings)
logger.info(f"Updated existing image endpoint: {base_url}")
return existing.id
ep_id = f"img-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="image",
endpoint_kind="local",
model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
settings = load_settings()
settings["image_gen_enabled"] = True
if not settings.get("image_model"):
settings["image_model"] = req.repo_id
save_settings(settings)
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register image endpoint: {e}")
db.rollback()
return None
finally:
db.close()
def _auto_register_llm_endpoint(
req: ServeRequest,
remote: str | None,
serve_session_id: str | None = None,
) -> str | None:
"""Register a freshly-served LLM as a model endpoint so it appears in the
model picker without a manual /setup step — the text-model sibling of
_auto_register_image_endpoint.
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
endpoint at that server's /v1; the picker auto-discovers the model id by
probing /v1/models and dims the endpoint until the server is reachable,
so registering immediately (before the server finishes loading) is safe.
"""
logger.info(
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
)
import re
from core.database import SessionLocal, ModelEndpoint
from routes.model_routes import _normalize_model_ids
# Port: ordered fallbacks so we match whatever the user actually
# asked for, not a hardcoded default:
# 1. explicit `--port N` (vllm / sglang / llama-server)
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
# Previously the OLLAMA_HOST form was silently ignored and we
# registered every Ollama endpoint at 11434 — even if the user
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
# existing systemd Ollama, the registered endpoint pointed at
# the OLD port and showed as offline.
port_match = re.search(r'--port\s+(\d+)', req.cmd)
# Host length is capped at the longest DNS name; unbounded, repeated
# assignments make this scan quadratic and stall the event loop.
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]{0,253}?:(\d+)', req.cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in req.cmd:
port = 11434
else:
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
# the odysseus container — so the right URL for the in-container
# backend to reach it is `localhost`, NOT `host.docker.internal`
# (the latter points at the docker HOST, which doesn't have a server
# on that port). The previous host.docker.internal fallback only made
# sense for /setup-added external services like systemd Ollama on the
# host — and those go through manual setup, not this auto-register
# code path. For remote serves we still use the SSH host alias.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
host = "host.docker.internal"
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = short_name or "Local model"
is_mlx_deepseek_v4 = (
"mlx_lm.server" in (req.cmd or "")
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
)
mlx_shim_model_id = ""
if is_mlx_deepseek_v4 and short_name:
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
remote_home = home_match.group(1) if home_match else ""
if remote_home:
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
# If the serve command opts models into OpenAI tool-calling, record it so
# agent_loop trusts emitted tool_calls instead of the name heuristic.
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
# Pin the model the user launched for every Cookbook-created LLM
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
# deployment alias from /v1/models, and a stale server can answer on the
# same port while the new launch failed. Keeping the requested model id
# pinned makes the picker reflect the actual launch intent.
launched_model = mlx_shim_model_id or req.repo_id
pinned_models = [launched_model] if launched_model else []
# Launch registration must not delete other endpoints.
# Matching names, model pins, and failed probes do not prove that
# another serve has stopped. Lifecycle cleanup owns termination.
db = SessionLocal()
try:
# Reuse an endpoint already pointed at this URL instead of duplicating.
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "llm"
# Follow the newly served model unless the user renamed the endpoint.
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
if existing.name in pinned_names:
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "auto"
# A reused endpoint belongs to the newest serve session.
existing.cookbook_serve_session_id = serve_session_id
if launched_model:
# Pins represent model selection, not launch history.
existing_pins = _normalize_model_ids(
existing.pinned_models
)
if launched_model not in existing_pins:
existing_pins.append(launched_model)
existing.pinned_models = json.dumps(existing_pins)
if is_ollama_endpoint:
existing.endpoint_kind = "ollama"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
if supports_tools is not None:
existing.supports_tools = supports_tools
db.commit()
logger.info(f"Updated existing local model endpoint: {base_url}")
# Re-probe so cached_models matches what the server actually
# serves right now (the URL may have stayed the same but the
# model behind it changed across launches).
try:
if mlx_shim_model_id:
existing.cached_models = json.dumps([mlx_shim_model_id])
existing.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
if probed:
existing.cached_models = _json2.dumps(probed)
db.commit()
except Exception as _pe:
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
return existing.id
ep_id = f"local-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="llm",
endpoint_kind="ollama" if is_ollama_endpoint else "local",
model_refresh_mode="auto",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
supports_tools=supports_tools,
cookbook_serve_session_id=serve_session_id,
)
db.add(ep)
db.commit()
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
# Probe /v1/models NOW and write cached_models so the chat
# picker actually shows the model on the next /api/models
# call. Without this immediate probe, the endpoint has empty
# cached_models until the next background refresh fires (up
# to a minute later) and the picker shows nothing — even
# though the endpoint is in the DB and the server is up.
try:
if mlx_shim_model_id:
ep.cached_models = json.dumps([mlx_shim_model_id])
ep.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, None, timeout=5)
if probed:
ep.cached_models = _json2.dumps(probed)
db.commit()
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
except Exception as _pe:
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register local model endpoint: {e}")
db.rollback()
return None
finally:
db.close()
+185 -2
View File
@@ -14,6 +14,7 @@ from fastapi import HTTPException
from pydantic import BaseModel
from routes._validators import validate_remote_host, validate_ssh_port
from routes.cookbook_output import HF_PARTIAL_BLOB_HELPER
from core.platform_compat import _ssh_exec_argv
logger = logging.getLogger(__name__)
@@ -378,6 +379,7 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
"""
lines = [
"import json, os, re, shutil, subprocess, urllib.request",
*HF_PARTIAL_BLOB_HELPER.splitlines(),
"models = []",
"seen = set()",
"BLOCKED_ROOTS = ('/sys', '/proc', '/dev', '/run', '/var/run')",
@@ -436,8 +438,10 @@ def _cached_model_scan_script(model_dirs: list[str] | None = None, add_hf_cache:
" sz, nf, ic = 0, 0, False",
" if os.path.isdir(blobs):",
" for f in os.scandir(blobs):",
" if f.name.endswith('.incomplete'):",
" if not is_partial_blob(f.path): continue",
" ic = True",
" if f.is_file(): nf += 1; sz += f.stat().st_size",
" if f.name.endswith('.incomplete'): ic = True",
" snap = os.path.join(cache, d, 'snapshots')",
" def snapshot_size():",
" total, count, incomplete = 0, 0, False",
@@ -724,8 +728,13 @@ def _check_serve_binary(seg: str) -> None:
if not tokens:
return
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
first = next((t for t in tokens if not env_re.match(t)), "")
idx = next((i for i, t in enumerate(tokens) if not env_re.match(t)), len(tokens))
first = tokens[idx] if idx < len(tokens) else ""
base = os.path.basename(first)
# The vLLM/SGLang Dependencies recipes install with `uv pip install …
# --torch-backend auto`. Allow uv for that subcommand only, not `uv run`.
if base == "uv" and tokens[idx + 1:idx + 3] == ["pip", "install"]:
return
if base not in _SERVE_CMD_ALLOWLIST:
raise HTTPException(
400,
@@ -1030,6 +1039,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_resolve_llama_server() {')
runner_lines.append(' _odysseus_llama_bin=""')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' local _saved_ifs="$IFS"')
runner_lines.append(" IFS=':'")
runner_lines.append(' local -a _parts')
runner_lines.append(' read -r -a _parts <<< "$PATH"')
runner_lines.append(' IFS="$_saved_ifs"')
runner_lines.append(' local _dir _cand')
runner_lines.append(' for _dir in "${_parts[@]}"; do')
runner_lines.append(' [ -n "$_dir" ] || continue')
runner_lines.append(' _cand="$_dir/llama-server"')
runner_lines.append(' [ -x "$_cand" ] || continue')
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
runner_lines.append(' continue')
runner_lines.append(' fi')
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
runner_lines.append(' _odysseus_llama_bin="$_cand"')
runner_lines.append(' break')
runner_lines.append(' fi')
runner_lines.append(' done')
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' local _cand_dir')
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
runner_lines.append(' fi')
runner_lines.append(' hash -r')
runner_lines.append(' return 0')
runner_lines.append(' fi')
runner_lines.append(' return 1')
runner_lines.append('}')
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that generates the Odysseus compatibility shim."""
_append_llama_server_resolution_definition_lines(runner_lines)
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_create_llama_shim() {')
runner_lines.append(' local _target="$HOME/bin/llama-server"')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
runner_lines.append(' : # existing Odysseus shim — safe to replace')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
runner_lines.append(' local _tmp')
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
runner_lines.append(' return 1')
runner_lines.append(' }')
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' then')
runner_lines.append(' rm -f -- "$_tmp"')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' hash -r')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append('}')
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
Invariants:
- Never touch an arbitrary user-owned executable or symlink.
- If ~/bin/llama-server is an Odysseus generated shim:
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
so the native binary is used.
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
and hash -r so the fallback/build path is triggered.
- If no healthy native server exists and llama_cpp.server is working, preserve it.
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
ensure the launched executable is the validated candidate.
"""
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('_odysseus_resolve_llama_server || true')
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
command is unresolvable, the compatibility launcher is created. If no usable
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
"""
err_msg = (
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
if platform_hint
else "ERROR: llama.cpp serving is not available after install/build attempts."
)
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_create_llama_shim')
runner_lines.append(' _odysseus_resolve_llama_server || true')
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(f' echo "{err_msg}"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
"""Shell command that clears the Cookbook-managed llama.cpp build.
@@ -1327,6 +1496,20 @@ def _diagnose_serve_output(text: str) -> dict | None:
"Port is already in use.",
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
),
(
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
[
{
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
"op": "manual",
},
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
],
),
(
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
"No GPUs are visible to the serve process.",
+18 -4
View File
@@ -8,6 +8,20 @@ import re
_FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# Shared source embedded in local/remote inventory and status probes. A blank
# retry marker beside its completed, nonempty content-addressed blob is stale;
# a blank marker alone may be a download that has not received its first byte.
HF_PARTIAL_BLOB_HELPER = """def is_partial_blob(path):
if not path.endswith('.incomplete'):
return False
try:
completed = path[:-len('.incomplete')]
return (os.path.getsize(path) != 0 or not os.path.isfile(completed)
or os.path.getsize(completed) == 0)
except OSError:
return True
"""
# Probe scripts for the dead-session download check, run as
# `python3 -c <PROBE> <repo_id> <cache_root>` (locally or over SSH).
# cache_root is the task's custom download dir, '' for the default HF cache.
@@ -15,7 +29,7 @@ _FETCHING_ZERO_FILES_RE = re.compile(r"Fetching\s+0\s+files", re.IGNORECASE)
# HF_HOME=<local_dir>, so that task's cache lives under <local_dir>/hub, and
# the probe process's own environment knows nothing about it.
HF_CACHE_COMPLETE_PROBE = (
"import os,sys;"
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
@@ -24,18 +38,18 @@ HF_CACHE_COMPLETE_PROBE = (
"ok=os.path.isdir(snap) and any(os.path.isdir(os.path.join(snap,x)) and os.listdir(os.path.join(snap,x)) for x in os.listdir(snap));"
"inc=False;"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
"sys.exit(0 if ok and not inc else 1)"
)
HF_CACHE_INCOMPLETE_PROBE = (
"import os,sys;"
"import os,sys\n" + HF_PARTIAL_BLOB_HELPER +
"repo=sys.argv[1];"
"root=os.path.expanduser(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2] else '';"
"base=os.path.join(root,'hub') if root else (os.environ.get('HUGGINGFACE_HUB_CACHE') or os.path.join(os.environ.get('HF_HOME', os.path.expanduser('~/.cache/huggingface')), 'hub'));"
"d=os.path.join(base,'models--'+repo.replace('/','--'));"
"blobs=os.path.join(d,'blobs');"
"inc=os.path.isdir(blobs) and any(x.endswith('.incomplete') for x in os.listdir(blobs));"
"inc=os.path.isdir(blobs) and any(is_partial_blob(os.path.join(blobs,x)) for x in os.listdir(blobs));"
"sys.exit(0 if inc else 1)"
)
+76 -524
View File
@@ -43,6 +43,9 @@ from routes.cookbook_output import (
error_aware_output_tail, classify_dead_download,
HF_CACHE_COMPLETE_PROBE, HF_CACHE_INCOMPLETE_PROBE,
)
from routes.cookbook.endpoint_registration import (
_auto_register_image_endpoint, _auto_register_llm_endpoint,
)
logger = logging.getLogger(__name__)
@@ -57,9 +60,11 @@ from routes.cookbook_helpers import (
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_diagnose_serve_output, run_ssh_command_async,
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
_normalize_llama_cpp_python_cache_types,
_append_llama_server_shim_definition_lines,
_append_llama_server_shim_reconciliation_lines,
_append_llama_server_finalization_lines,
ModelDownloadRequest, ServeRequest,
)
@@ -466,175 +471,6 @@ def setup_cookbook_routes() -> APIRouter:
task["payload"].pop("hf_token", None)
return state
def _diagnose_serve_output(text: str) -> dict | None:
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
the agent/tool path the same structured signal so it can retry with an
adjusted command instead of guessing from raw tmux output.
"""
if not text:
return None
tail = text[-6000:]
patterns = [
(
r"No available memory for the cache blocks|Available KV cache memory:.*-",
"No GPU memory left for KV cache after loading model.",
[
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
],
),
(
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
"GPU ran out of memory during startup or warmup.",
[
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
],
),
(
r"not divisib|must be divisible|attention heads.*divisible",
"Tensor parallel size is incompatible with the model.",
[
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
],
),
(
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
"Context length is too large for available GPU memory.",
[
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
],
),
(
r"enable-auto-tool-choice requires --tool-call-parser",
"Auto tool choice requires an explicit tool call parser.",
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
),
(
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
"Model requires custom code or newer model support.",
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
),
(
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
"vLLM/Transformers kernel package mismatch.",
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
),
(
r"Address already in use|bind.*address.*in use",
"Port is already in use.",
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
),
(
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
"No GPUs are visible to the serve process.",
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
),
(
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
"vLLM could not find a supported GPU (CUDA or ROCm). "
"This machine may have integrated or unsupported graphics only.",
[
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
],
),
(
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
"vLLM is not installed or not in PATH on this server.",
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
),
(
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
r"Could not load any common_ops library|"
r"Please ensure sgl_kernel is properly installed",
"SGLang native kernel/runtime is missing or mismatched on this server.",
[
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
],
),
(
r"sglang.*command not found|No module named sglang|SGLang is not installed",
"SGLang is not installed or not in PATH on this server.",
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
),
(
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
"MLX LM is not installed on this server.",
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
),
(
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
),
(
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
[
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
],
),
# System build deps come BEFORE the generic llama.cpp catch-all
# so cmake / build-essential / git missing → a specific OS-package
# remediation instead of "install llama-cpp-python[server]" (which
# itself fails to compile when cmake is absent).
(
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
"cmake is required to build llama.cpp from source but isn't installed on this server.",
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"^git: command not found",
"git is required to clone the llama.cpp source tree.",
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
"llama.cpp / llama-cpp-python dependencies are missing.",
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
),
(
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
),
(
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
),
(
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
"Model access is gated or unauthorized.",
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
),
]
for pattern, message, suggestions in patterns:
if re.search(pattern, tail, re.I):
return {"message": message, "suggestions": suggestions}
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
):
return {
"message": "Python traceback detected during serve startup.",
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
}
return None
def _state_for_client(state):
"""Return cookbook state without raw secrets for browser clients."""
_strip_task_secrets(state)
@@ -1575,80 +1411,6 @@ def setup_cookbook_routes() -> APIRouter:
return {"models": models, "host": host or "local"}
def _auto_register_image_endpoint(req: ServeRequest, remote: str | None) -> str | None:
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
import re
from core.database import SessionLocal, ModelEndpoint
from src.settings import load_settings, save_settings
# Parse port from command (--port NNNN), default 8100 for diffusion_server
port_match = re.search(r'--port\s+(\d+)', req.cmd)
port = int(port_match.group(1)) if port_match else 8100
# Determine host
if remote:
# SSH alias — use as hostname (Tailscale resolves it later)
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
# Friendly display name from repo_id
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = f"{short_name} (image)"
pinned_models = [req.repo_id] if req.repo_id else []
db = SessionLocal()
try:
# Check for existing endpoint with same base_url — update it
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "image"
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "manual"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
existing.pinned_models = json.dumps(pinned_models)
db.commit()
settings = load_settings()
if settings.get("image_gen_enabled") is not True:
settings["image_gen_enabled"] = True
save_settings(settings)
logger.info(f"Updated existing image endpoint: {base_url}")
return existing.id
ep_id = f"img-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="image",
endpoint_kind="local",
model_refresh_mode="manual",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
)
db.add(ep)
db.commit()
settings = load_settings()
settings["image_gen_enabled"] = True
if not settings.get("image_model"):
settings["image_model"] = req.repo_id
save_settings(settings)
logger.info(f"Auto-registered image endpoint: {display_name} @ {base_url}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register image endpoint: {e}")
db.rollback()
return None
finally:
db.close()
def _pick_free_port_for_ollama(
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
) -> int | None:
@@ -1777,244 +1539,66 @@ def setup_cookbook_routes() -> APIRouter:
# let the probe layer mark it offline if nothing's listening.
logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}")
return
# Non-zero exit — drop the endpoint.
# A nonzero exit may delete only the endpoint owned by this serve.
try:
from core.database import SessionLocal as _SL, ModelEndpoint as _ME
from core.constants import internal_api_base as _internal_api_base
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
from urllib.parse import quote
import httpx
db = _SL()
try:
ep = db.query(_ME).filter(_ME.id == endpoint_id).first()
if ep:
# A scheduled serve can leave old non-zero exit markers
# in tmux scrollback while the current OpenAI endpoint is
# actually alive. Verify reachability before deleting the
# endpoint row; otherwise chats fall back even though the
# served model is ready.
try:
if not ep or ep.cookbook_serve_session_id != session_id:
logger.info(
"crash-watchdog: endpoint %s no longer owned by %s",
endpoint_id, session_id,
)
return
probe_url = ep.base_url.rstrip("/") + "/models"
finally:
db.close()
# Preserve an endpoint that is already healthy, including
# after an old runner emits a stale failure marker.
try:
with urllib.request.urlopen(probe_url, timeout=3) as resp:
if 200 <= getattr(resp, "status", 0) < 300:
logger.info(
f"crash-watchdog: serve {session_id} has exit marker {exit_code} "
f"but endpoint {ep.id} is reachable; leaving it registered"
"crash-watchdog: endpoint %s is reachable; keeping it",
endpoint_id,
)
return
except Exception:
pass
async with httpx.AsyncClient(timeout=8) as client:
response = await client.delete(
f"{_internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
params={"expected_cookbook_serve_session_id": session_id},
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN},
)
if response.status_code == 200:
logger.info(
f"crash-watchdog: dropping endpoint {endpoint_id} "
f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}"
"crash-watchdog: deleted endpoint %s after serve %s exited %s",
endpoint_id, session_id, exit_code,
)
elif response.status_code in (404, 409):
logger.info(
"crash-watchdog: endpoint %s absent or reassigned; keeping it",
endpoint_id,
)
else:
logger.warning(
"crash-watchdog: endpoint %s deletion returned HTTP %s",
endpoint_id, response.status_code,
)
db.delete(ep)
db.commit()
finally:
db.close()
except Exception as e:
logger.warning(f"crash-watchdog: endpoint cleanup failed: {e!r}")
logger.warning("crash-watchdog: endpoint cleanup failed: %r", e)
return
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None:
"""Register a freshly-served LLM as a model endpoint so it appears in the
model picker without a manual /setup step — the text-model sibling of
_auto_register_image_endpoint.
Cookbook serve commands launch an OpenAI-compatible server (llama.cpp's
llama-server, vLLM, SGLang, or Ollama) on a known port. We point an
endpoint at that server's /v1; the picker auto-discovers the model id by
probing /v1/models and dims the endpoint until the server is reachable,
so registering immediately (before the server finishes loading) is safe.
"""
logger.info(
f"_auto_register_llm_endpoint: ENTRY repo_id={req.repo_id!r} "
f"remote={remote!r} cmd_prefix={req.cmd[:80]!r}"
)
import re
from core.database import SessionLocal, ModelEndpoint
# Port: ordered fallbacks so we match whatever the user actually
# asked for, not a hardcoded default:
# 1. explicit `--port N` (vllm / sglang / llama-server)
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
# Previously the OLLAMA_HOST form was silently ignored and we
# registered every Ollama endpoint at 11434 — even if the user
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
# existing systemd Ollama, the registered endpoint pointed at
# the OLD port and showed as offline.
port_match = re.search(r'--port\s+(\d+)', req.cmd)
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]*?:(\d+)', req.cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in req.cmd:
port = 11434
else:
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
# Determine host. The cookbook tmux for `local=true` serves runs INSIDE
# the odysseus container — so the right URL for the in-container
# backend to reach it is `localhost`, NOT `host.docker.internal`
# (the latter points at the docker HOST, which doesn't have a server
# on that port). The previous host.docker.internal fallback only made
# sense for /setup-added external services like systemd Ollama on the
# host — and those go through manual setup, not this auto-register
# code path. For remote serves we still use the SSH host alias.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
elif re.search(r"\bdocker\s+exec\s+(?:ollama-rocm|ollama-test)\b", req.cmd or ""):
host = "host.docker.internal"
else:
host = "localhost"
base_url = f"http://{host}:{port}/v1"
short_name = req.repo_id.split("/")[-1] if "/" in req.repo_id else req.repo_id
display_name = short_name or "Local model"
is_mlx_deepseek_v4 = (
"mlx_lm.server" in (req.cmd or "")
and "deepseek-v4" in ((req.repo_id or "") + " " + (req.cmd or "")).lower()
)
mlx_shim_model_id = ""
if is_mlx_deepseek_v4 and short_name:
home_match = re.search(r"((?:/Users|/home)/[^/\s'\"]+)", req.cmd or "")
remote_home = home_match.group(1) if home_match else ""
if remote_home:
mlx_shim_model_id = f"{remote_home}/.cache/odysseus/mlx-shims/{short_name}"
# If the serve command opts models into OpenAI tool-calling, record it so
# agent_loop trusts emitted tool_calls instead of the name heuristic.
is_ollama_endpoint = "ollama" in (req.cmd or "").lower()
supports_tools = True if "--enable-auto-tool-choice" in req.cmd else None
# Pin the model the user launched for every Cookbook-created LLM
# endpoint, not just Ollama. Some OpenAI-compatible servers report a
# deployment alias from /v1/models, and a stale server can answer on the
# same port while the new launch failed. Keeping the requested model id
# pinned makes the picker reflect the actual launch intent.
pinned_models = [mlx_shim_model_id] if mlx_shim_model_id else ([req.repo_id] if req.repo_id else [])
db = SessionLocal()
try:
# Reuse an endpoint already pointed at this URL instead of duplicating.
existing = db.query(ModelEndpoint).filter(ModelEndpoint.base_url == base_url).first()
if existing:
existing.is_enabled = True
existing.model_type = "llm"
existing.name = display_name
existing.endpoint_kind = "local"
existing.model_refresh_mode = "auto"
if pinned_models:
try:
existing_pinned = json.loads(existing.pinned_models or "[]")
except Exception:
existing_pinned = []
merged_pinned = []
for mid in [*existing_pinned, *pinned_models]:
if mid and mid not in merged_pinned:
merged_pinned.append(mid)
existing.pinned_models = json.dumps(merged_pinned) if merged_pinned else None
if is_ollama_endpoint:
existing.endpoint_kind = "ollama"
if pinned_models:
existing.cached_models = json.dumps(pinned_models)
if supports_tools is not None:
existing.supports_tools = supports_tools
db.commit()
logger.info(f"Updated existing local model endpoint: {base_url}")
# Re-probe so cached_models matches what the server actually
# serves right now (the URL may have stayed the same but the
# model behind it changed across launches).
try:
if mlx_shim_model_id:
existing.cached_models = json.dumps([mlx_shim_model_id])
existing.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, existing.api_key, timeout=5)
if probed:
existing.cached_models = _json2.dumps(probed)
db.commit()
except Exception as _pe:
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
# Sweep stale dupes: other endpoints with the same display name
# at DIFFERENT URLs (likely failed earlier-attempt ports) get
# deleted so the picker doesn't show an offline ghost next to
# the working one. Only sweeps endpoints whose id starts with
# `local-` so we never touch a user's hand-added DeepSeek/OpenAI/
# etc. entry with a coincidentally matching name.
stale = (db.query(ModelEndpoint)
.filter(ModelEndpoint.name == display_name)
.filter(ModelEndpoint.base_url != base_url)
.filter(ModelEndpoint.id.like("local-%"))
.all())
for s in stale:
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
db.delete(s)
if stale:
db.commit()
return existing.id
ep_id = f"local-{uuid.uuid4().hex[:8]}"
ep = ModelEndpoint(
id=ep_id,
name=display_name,
base_url=base_url,
api_key=None,
is_enabled=True,
model_type="llm",
endpoint_kind="ollama" if is_ollama_endpoint else "local",
model_refresh_mode="auto",
cached_models=json.dumps(pinned_models) if pinned_models else None,
pinned_models=json.dumps(pinned_models) if pinned_models else None,
supports_tools=supports_tools,
)
db.add(ep)
db.commit()
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
# Same sweep on first-register path: drop any pre-existing local-*
# endpoints with this display name pointed elsewhere.
stale = (db.query(ModelEndpoint)
.filter(ModelEndpoint.name == display_name)
.filter(ModelEndpoint.id != ep_id)
.filter(ModelEndpoint.id.like("local-%"))
.all())
for s in stale:
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
db.delete(s)
if stale:
db.commit()
# Probe /v1/models NOW and write cached_models so the chat
# picker actually shows the model on the next /api/models
# call. Without this immediate probe, the endpoint has empty
# cached_models until the next background refresh fires (up
# to a minute later) and the picker shows nothing — even
# though the endpoint is in the DB and the server is up.
try:
if mlx_shim_model_id:
ep.cached_models = json.dumps([mlx_shim_model_id])
ep.pinned_models = json.dumps([mlx_shim_model_id])
db.commit()
logger.info(f"Auto-register: pinned MLX DeepSeek-V4 shim model @ {base_url}")
else:
from routes.model_routes import _probe_endpoint
import json as _json2
probed = _probe_endpoint(base_url, None, timeout=5)
if probed:
ep.cached_models = _json2.dumps(probed)
db.commit()
logger.info(f"Auto-register: probed {len(probed)} models @ {base_url}")
except Exception as _pe:
logger.warning(f"Auto-register: probe-after-create failed for {base_url}: {_pe!r}")
return ep_id
except Exception as e:
logger.error(f"Failed to auto-register local model endpoint: {e}")
db.rollback()
return None
finally:
db.close()
@router.post("/api/model/serve")
async def model_serve(request: Request, req: ServeRequest):
"""Launch a model server in a tmux session (or PowerShell background process on Windows).
@@ -2161,7 +1745,7 @@ def setup_cookbook_routes() -> APIRouter:
ps_lines.append('}')
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
ps_lines.append('# Auto-install llama-cpp-python if missing')
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
ps_lines.append(' python -m pip install llama-cpp-python[server]')
@@ -2247,14 +1831,17 @@ def setup_cookbook_routes() -> APIRouter:
# ollama is found (otherwise macOS falls back to a slow source build).
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
_append_llama_server_shim_definition_lines(runner_lines)
_append_llama_server_shim_reconciliation_lines(runner_lines)
runner_lines.append('if [ -d /data/data/com.termux ]; then')
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' pkg install -y cmake 2>/dev/null')
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
runner_lines.append(' fi')
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
@@ -2271,8 +1858,7 @@ def setup_cookbook_routes() -> APIRouter:
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
# explicit so the binary is optimized (Metal auto-enables on macOS).
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
runner_lines.append(' else')
_append_llama_cpp_linux_accel_build_lines(runner_lines)
runner_lines.append(' fi')
@@ -2296,58 +1882,13 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' fi')
# SHORT-CIRCUIT before the build/pip fallback: if the
# native binary is missing but llama_cpp Python is already
# installed, drop a wrapper at ~/bin/llama-server that
# translates llama-server CLI args to llama_cpp.server's
# underscore-style flags. The user's serve command stays
# `llama-server ...` and "just works" — no build, no cmake,
# no second install. This is the path that unblocks every
# remote where pip-installed llama-cpp-python is already
# working but Cookbook used to insist on a native binary.
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' chmod +x ~/bin/llama-server')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append(' fi')
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' _odysseus_resolve_llama_server || true')
# If the native build failed, fall back to the Python bindings.
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
runner_lines.append(' fi')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
_append_llama_server_finalization_lines(runner_lines)
runner_lines.append('fi')
elif re.search(r"\bollama\s+serve\b", req.cmd):
handled_ollama_serve = True
@@ -2849,9 +2390,17 @@ def setup_cookbook_routes() -> APIRouter:
endpoint_id = None
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
if is_image_endpoint:
endpoint_id = _auto_register_image_endpoint(req, remote)
endpoint_id = _auto_register_image_endpoint(
req,
remote,
serve_session_id=session_id,
)
elif not is_pip_install:
endpoint_id = _auto_register_llm_endpoint(req, remote)
endpoint_id = _auto_register_llm_endpoint(
req,
remote,
serve_session_id=session_id,
)
# Crash watchdog: the auto-register above writes the endpoint row
# IMMEDIATELY (before the server has even bound its port) so the
@@ -3971,7 +3520,7 @@ def setup_cookbook_routes() -> APIRouter:
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
data = resp.json()
except Exception:
logger.exception("HF GGUF file scan failed for %s", repo)
logger.exception("HF GGUF file scan failed for %s", repo_id)
return {"ok": False, "files": [], "error": "HF API request failed"}
files = [
str(s.get("rfilename") or "")
@@ -4343,7 +3892,7 @@ def setup_cookbook_routes() -> APIRouter:
tmux output can stop at a stale progress line if the pane/session
disappears before Cookbook captures the final DOWNLOAD_OK marker.
In that case, trust the cache shape: a snapshot directory with files
and no *.incomplete blobs means HuggingFace finished materializing the
and no unfinished blobs means HuggingFace finished materializing the
model. cache_root is the task's custom download dir — the runner
pointed HF_HOME there, so the cache lives under <cache_root>/hub,
not wherever this probe's environment says.
@@ -4368,7 +3917,8 @@ def setup_cookbook_routes() -> APIRouter:
"""Best-effort check for resumable HF partial blobs.
A lost SSH/tmux session can leave a real download still incomplete.
Treat any *.incomplete blob as stronger evidence than stale
Treat unfinished blobs (excluding empty markers paired with a
completed blob) as stronger evidence than stale
"100%" lines in the captured pane output.
"""
if not repo_id or "/" not in repo_id:
@@ -4486,7 +4036,9 @@ def setup_cookbook_routes() -> APIRouter:
# fills the visible tail. Without this, output_tail ends up
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
# can't diagnose the actual error.
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
# -J joins lines tmux wrapped at the pane width, so an error
# split mid-word still matches the serve diagnosis patterns.
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
elif IS_WINDOWS:
# LOCAL Windows task: launched as a detached process (no tmux).
# Liveness comes from the <session>.pid file, output from the
@@ -4495,7 +4047,7 @@ def setup_cookbook_routes() -> APIRouter:
capture_cmd = None
else:
check_cmd = ["tmux", "has-session", "-t", session_id]
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
local_win_task = (not remote) and IS_WINDOWS
+16 -9
View File
@@ -16,6 +16,7 @@ from core.database import SessionLocal, Document, DocumentVersion
from core.database import Session as DbSession
from src.auth_helpers import get_current_user, _auth_disabled
from src.constants import MAIL_ATTACHMENTS_DIR
from src.session_search import _escape_like
from src.upload_handler import reserve_upload_references
logger = logging.getLogger(__name__)
@@ -571,9 +572,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
# match docs containing both words regardless of position/order.
if search:
for tok in search.split():
term = f"%{tok}%"
term = f"%{_escape_like(tok)}%"
q = q.filter(
Document.title.ilike(term) | Document.current_content.ilike(term)
Document.title.ilike(term, escape="\\")
| Document.current_content.ilike(term, escape="\\")
)
# Language filter. "pdf" is a display language derived from the
@@ -781,9 +783,11 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
except Exception as e:
logger.warning("Failed to parse export request body, defaulting to empty", exc_info=e)
data = {}
ids = data.get("ids") or []
ids = data.get("ids") if isinstance(data, dict) else None
if not ids:
raise HTTPException(400, "No documents specified")
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
raise HTTPException(400, "ids must be a list of document ids")
_ext = {
"javascript": ".js", "python": ".py", "html": ".html", "css": ".css",
"markdown": ".md", "json": ".json", "yaml": ".yml", "bash": ".sh",
@@ -810,10 +814,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
ext = _ext.get(doc.language or "text", ".txt")
base = (doc.title or "document").strip() or "document"
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
name = base if "." in base else base + ext
has_ext = base.lower().endswith(ext)
stem = base[:-len(ext)] if has_ext else base
suffix = base[-len(ext):] if has_ext else ext
name = stem + suffix
i = 1
while name in used:
name = f"{base}-{i}" + ("" if "." in base else ext)
name = f"{stem}-{i}{suffix}"
i += 1
used.add(name)
content = doc.current_content or ""
@@ -1185,7 +1192,7 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
async def ai_tidy_documents(request: Request) -> Dict[str, Any]:
"""Use AI to judge if documents are junk/test/accidental, then delete them.
Caches verdicts so previously-reviewed docs are skipped."""
from src.task_endpoint import resolve_task_endpoint
from src.task_endpoint import resolve_task_endpoint, task_llm_call_async
from src.endpoint_resolver import resolve_endpoint
from src.llm_core import llm_call_async
@@ -1228,13 +1235,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
+ "\n".join(doc_list)
)
response = await llm_call_async(
url, model,
response = await task_llm_call_async(
[{"role": "system", "content": "You classify documents as junk or keep. Respond only with a JSON array."},
{"role": "user", "content": prompt}],
fallback_url=url, fallback_model=model, fallback_headers=headers,
owner=user or None,
temperature=0.1,
max_tokens=200,
headers=headers,
timeout=30,
)
File diff suppressed because it is too large Load Diff
+18 -23
View File
@@ -36,6 +36,7 @@ from fastapi import Query, HTTPException, Request
from pydantic import BaseModel
from typing import Optional, List
from src.email_mime import decode_text_part as _decode_text_part
from src.auth_helpers import _auth_disabled, get_current_user
from src.secret_storage import decrypt as _decrypt
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
@@ -1776,44 +1777,38 @@ def _extract_html(msg):
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/html" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
return text
elif msg.get_content_type() == "text/html":
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
return payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
return text
return None
def _extract_text(msg):
if msg.is_multipart():
text_parts = []
html_parts = []
for part in msg.walk():
ct = part.get_content_type()
cd = str(part.get("Content-Disposition", ""))
if ct == "text/plain" and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
text_parts.append(payload.decode(charset, errors="replace"))
elif ct == "text/html" and not text_parts and "attachment" not in cd:
payload = part.get_payload(decode=True)
if payload:
charset = part.get_content_charset() or "utf-8"
raw_html = payload.decode(charset, errors="replace")
text = _decode_text_part(part)
if text:
text_parts.append(text)
elif ct == "text/html" and not html_parts and "attachment" not in cd:
raw_html = _decode_text_part(part)
if raw_html:
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
text = html.unescape(text)
text_parts.append(text.strip())
return "\n".join(text_parts)
html_parts.append(text.strip())
return "\n".join(text_parts or html_parts)
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
text = payload.decode(charset, errors="replace")
text = _decode_text_part(msg)
if text:
if msg.get_content_type() == "text/html":
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
+195 -25
View File
@@ -43,6 +43,7 @@ from starlette.concurrency import run_in_threadpool
from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request
from fastapi.responses import FileResponse, StreamingResponse
from src.constants import DATA_DIR
from src import shutdown as _shutdown
from src.path_confinement import confine
from src.llm_core import llm_call_async
@@ -87,6 +88,17 @@ _SERVER_OWNED_OAUTH_FIELDS = {
_CACHE_HOOKS: dict = {"invalidate_list": None}
def _check_mail_host(host, owner: str, label: str) -> str | None:
"""Reject unsafe or unresolved mail hosts before saving or testing them."""
from src.url_safety import check_outbound_host
ok, reason = check_outbound_host(host, block_private=_mail_private_blocked(owner))
if ok:
return None
logger.warning("Rejected %s mail host for owner=%r: %s", label, owner, reason)
# Resolver and exception details belong in server logs, never the response.
return f"Rejected {label} host: server address is not allowed or does not resolve"
def _normalized_mail_host(value) -> str:
"""Normalize a mail hostname for exact provider-bound comparisons."""
return str(value or "").strip().lower().rstrip(".")
@@ -1815,7 +1827,15 @@ def setup_email_routes():
# a cached message body.
return (account_id or "", folder, str(uid), owner)
_LIST_STALE = {}
_LIST_LOCK = _threading.RLock()
_LIST_CACHE_LOCK = _LIST_LOCK
# Invalidation fences reads already in flight, including background refresh.
_LIST_EPOCH = [0]
_LIST_CACHE_GENERATION = _LIST_EPOCH
def _list_cache_get(key):
with _LIST_LOCK:
v = _LIST_CACHE.get(key)
if not v: return None
if v[0] < _time.monotonic():
@@ -1823,13 +1843,60 @@ def setup_email_routes():
return None
return v[1]
def _list_stale_get(key):
with _LIST_LOCK:
return _LIST_STALE.get(key)
def _list_stale_put(key, value):
_LIST_STALE[key] = value
if len(_LIST_STALE) > 96:
for k in list(_LIST_STALE.keys())[:-48]:
_LIST_STALE.pop(k, None)
_LIST_REFRESHING = set()
def _list_cache_put(key, value):
with _LIST_LOCK:
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
# Cap size
# Also snapshot the last-good response for stale-while-revalidate.
_list_stale_put(key, value)
if len(_LIST_CACHE) > 64:
for k in list(_LIST_CACHE.keys())[:-32]:
_LIST_CACHE.pop(k, None)
def _schedule_list_refresh(sync_args, cache_key, folder, account_id, owner):
"""Refresh a list snapshot off the event loop, at most once per key.
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
the TTL cache and the stale snapshot so the next list view is warm.
"""
with _LIST_LOCK:
if cache_key in _LIST_REFRESHING:
return
_LIST_REFRESHING.add(cache_key)
epoch = _LIST_EPOCH[0]
def _run():
try:
refreshed = _list_emails_sync(*sync_args)
if refreshed and not refreshed.get("error"):
with _LIST_LOCK:
if epoch != _LIST_EPOCH[0]:
return
_list_cache_put(cache_key, refreshed)
try:
_schedule_recent_email_warm(
refreshed.get("emails") or [], folder, account_id, owner)
except Exception:
pass
except Exception as exc: # pragma: no cover - background best effort
logger.debug("email list background refresh failed: %s", exc)
finally:
with _LIST_LOCK:
_LIST_REFRESHING.discard(cache_key)
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
def _folder_cache_get(account_id, owner):
key = (account_id or "", owner or "")
v = _FOLDER_CACHE.get(key)
@@ -1863,18 +1930,33 @@ def setup_email_routes():
hook(account_id, folder)
except Exception:
pass
with _LIST_LOCK:
_LIST_EPOCH[0] += 1
if account_id is None and folder is None:
_LIST_CACHE.clear()
_LIST_STALE.clear()
return
for k in list(_LIST_CACHE.keys()):
for cache in (_LIST_CACHE, _LIST_STALE):
for k in list(cache.keys()):
k_acct = k[0] if len(k) > 0 else ""
k_folder = k[1] if len(k) > 1 else ""
if (account_id is None or k_acct == (account_id or "")) and \
(folder is None or k_folder == folder):
_LIST_CACHE.pop(k, None)
cache.pop(k, None)
def _index_update_flags(owner, account_id, folder, uid, flag, add):
with _LIST_LOCK:
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
_invalidate_list_cache(account_id, folder)
def _index_delete(owner, account_id, folder, uid):
with _LIST_LOCK:
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
uid_s = str(uid)
with _LIST_LOCK:
for key, (expires_at, value) in list(_LIST_CACHE.items()):
if key[0] != (account_id or "") or key[1] != folder:
continue
@@ -1894,6 +1976,28 @@ def setup_email_routes():
changed = True
if changed:
_LIST_CACHE[key] = (expires_at, value)
if key in _LIST_STALE:
_LIST_STALE[key] = value
for key, value in list(_LIST_STALE.items()):
if key[0] != (account_id or "") or key[1] != folder:
continue
emails = list((value or {}).get("emails") or [])
changed = False
if seen and key[2] == "unread":
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
if len(kept) != len(emails):
value = dict(value)
value["emails"] = kept
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
changed = True
else:
for e in emails:
if str((e or {}).get("uid") or "") == uid_s:
e["is_read"] = bool(seen)
changed = True
if changed:
_LIST_STALE[key] = value
def _read_cache_get(key):
v = _READ_CACHE.get(key)
@@ -2273,6 +2377,8 @@ def setup_email_routes():
SECURITY: `owner` is propagated so when `account_id` is missing,
the fallback config lookup is scoped to this user's accounts only.
"""
with _LIST_CACHE_LOCK:
index_generation = _LIST_CACHE_GENERATION[0]
conn = None
conn_ok = False
try:
@@ -2567,6 +2673,8 @@ def setup_email_routes():
if parsed:
fetched_emails.append(parsed)
cached_rows[parsed["uid"]] = parsed
with _LIST_CACHE_LOCK:
if index_generation == _LIST_CACHE_GENERATION[0]:
_email_index_upsert(owner, account_id, folder, fetched_emails)
for uid_num in uid_order:
row = cached_rows.get(uid_num)
@@ -2856,6 +2964,48 @@ def setup_email_routes():
owner, account_id or "", folder, filter, limit, offset, elapsed_ms,
)
return cached
# Stale-while-revalidate: never block a plain list view on IMAP while
# we already hold a recent snapshot or a durable index. Serve it now
# with a `stale` marker and refresh in the background so the next view
# is warm. Blocking IMAP only remains for a genuine cold start with no
# snapshot and no indexed rows (e.g. a brand-new account/folder).
if not manual_refresh and not from_addr and not has_attachments and not (date_from or date_to):
snapshot = _list_stale_get(ck)
if snapshot is None:
try:
indexed_emails, indexed_total, indexed_at = _email_index_list(
owner, account_id, folder, filter, limit, offset, False,
)
except Exception:
indexed_emails, indexed_total, indexed_at = [], 0, None
if indexed_total:
snapshot = {
"emails": indexed_emails,
"total": indexed_total,
"folder": folder,
"offset": offset,
"sync": {
"source": "index",
"indexed": indexed_total,
"updated_at": indexed_at,
},
}
if snapshot is not None:
payload = dict(snapshot)
payload["emails"] = list(payload.get("emails") or [])
_hide_unlinked_calendar_tags(payload["emails"])
sync_meta = dict(payload.get("sync") or {})
sync_meta["source"] = "cache_stale" if _list_stale_get(ck) is not None else "index"
sync_meta["stale"] = True
payload["sync"] = sync_meta
_schedule_list_refresh(
(folder, limit, offset, filter, account_id, from_addr,
bool(has_attachments), owner, True, date_from or "", date_to or ""),
ck, folder, account_id, owner,
)
return payload
with _LIST_CACHE_LOCK:
cache_generation = _LIST_CACHE_GENERATION[0]
result = await _asyncio.to_thread(
_list_emails_sync, folder, limit, offset, filter, account_id, from_addr,
bool(has_attachments), owner, manual_refresh, date_from or "", date_to or "",
@@ -2864,6 +3014,8 @@ def setup_email_routes():
if offset == 0 and not from_addr and not has_attachments and filter in ("all", "unread", "unanswered", "undone"):
_record_email_received_events(owner, account_id, folder, result.get("emails") or [])
_schedule_recent_email_warm(result.get("emails") or [], folder, account_id, owner)
with _LIST_CACHE_LOCK:
if cache_generation == _LIST_CACHE_GENERATION[0]:
_list_cache_put(ck, result)
elapsed_ms = int((_time.monotonic() - started_at) * 1000)
if elapsed_ms > 1500:
@@ -3212,7 +3364,7 @@ def setup_email_routes():
conn.select(_q(folder))
moved = _move_email_message(conn, uid, "Junk", role="junk")
if moved:
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
except Exception:
logger.debug("unsubscribe move-to-spam skipped", exc_info=True)
@@ -3229,7 +3381,7 @@ def setup_email_routes():
if deleted:
conn.expunge()
if deleted:
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
except Exception as exc:
delete_error = "Email was unsubscribed but could not be moved to Trash"
@@ -3311,7 +3463,7 @@ def setup_email_routes():
if moved:
changed += 1
cleaned_uids.append(uid)
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
else:
failed += 1
except Exception:
@@ -3400,6 +3552,8 @@ def setup_email_routes():
folder (Gmail does), we transparently swap to All Mail so the
search surfaces archived / labelled emails too. Plain IMAP
accounts fall back to whatever folder the caller specified."""
with _LIST_CACHE_LOCK:
index_generation = _LIST_CACHE_GENERATION[0]
if not q or len(q) < 2:
return {"emails": [], "total": 0, "query": q}
# CRLF in q would terminate the IMAP command early — reject defensively.
@@ -3543,6 +3697,8 @@ def setup_email_routes():
except Exception as e:
logger.warning(f"Error parsing search result {uid}: {e}")
continue
with _LIST_CACHE_LOCK:
if index_generation == _LIST_CACHE_GENERATION[0]:
_email_index_upsert(owner, account_id, effective_folder, fetched_emails)
return {
@@ -3680,8 +3836,7 @@ def setup_email_routes():
# accepted it, so the index and list cache cannot drift ahead of
# the mailbox.
if mark_seen and not mark_seen_failed:
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_update_list_cache_seen(account_id, folder, uid, True)
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
related_attachments = []
if full and not _has_visible_attachments(msg):
@@ -3820,8 +3975,7 @@ def setup_email_routes():
status, _ = conn.uid("STORE", _uid_bytes(uid), "+FLAGS", "(\\Seen)")
if status != "OK":
return False
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_update_list_cache_seen(account_id, folder, uid, True)
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
return True
except Exception as e:
logger.warning(f"mark-seen after cached read failed uid={uid}: {e}")
@@ -5053,7 +5207,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Seen", add=False):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
_index_update_flags(owner, account_id, folder, uid, "\\Seen", False)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5073,7 +5227,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Flagged", add=bool(on)):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
_index_update_flags(owner, account_id, folder, uid, "\\Flagged", bool(on))
_invalidate_list_cache(account_id, folder)
return {"success": True, "flagged": bool(on)}
except Exception as e:
@@ -5091,7 +5245,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Seen", add=True):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_index_update_flags(owner, account_id, folder, uid, "\\Seen", True)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5111,7 +5265,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _move_email_message(conn, uid, "Archive", role="archive"):
return {"success": False, "error": "Email not found"}
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5142,7 +5296,7 @@ def setup_email_routes():
# click or by another mailbox client; it is already gone
# from the requested folder, so do not trap the UI on a
# permanent “Email not found” error.
_email_index_delete(owner, account_id, folder, str(uid))
_index_delete(owner, account_id, folder, str(uid))
_invalidate_list_cache(account_id, folder)
return {"success": True, "already_deleted": True}
trash_folder = _resolve_mail_folder(conn, "Trash", "trash")
@@ -5160,9 +5314,9 @@ def setup_email_routes():
if not _move_email_message(conn, resolved_uid, trash_folder, role="trash"):
logger.warning("Email delete Trash move failed uid=%s resolved_uid=%s folder=%s trash=%s", uid, resolved_uid, folder, trash_folder)
return {"success": False, "error": "Could not move email to Trash"}
_email_index_delete(owner, account_id, folder, resolved_uid)
_index_delete(owner, account_id, folder, resolved_uid)
if resolved_uid != str(uid):
_email_index_delete(owner, account_id, folder, str(uid))
_index_delete(owner, account_id, folder, str(uid))
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5178,7 +5332,7 @@ def setup_email_routes():
if not _store_email_flag(conn, uid, "\\Deleted", add=True):
return {"success": False, "error": "Email not found"}
conn.expunge()
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -5246,7 +5400,7 @@ def setup_email_routes():
pending_expunge.append(uid)
continue
deleted_uids.append(uid)
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
except Exception:
failed_uids.append(uid)
logger.debug("Bulk email delete failed for uid=%s", uid, exc_info=True)
@@ -5268,7 +5422,7 @@ def setup_email_routes():
if expunge_ok:
for uid in pending_expunge:
deleted_uids.append(uid)
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
else:
# Prioritize preservation over cleanup: unflag \Deleted from our
# copied messages so future EXPUNGE operations do not destroy them.
@@ -5387,7 +5541,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _move_email_message(conn, uid, dest):
return {"success": False, "error": f"Failed to move to {dest}"}
_email_index_delete(owner, account_id, folder, uid)
_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id)
return {"success": True}
except Exception as e:
@@ -5473,7 +5627,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Answered", add=True):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
_index_update_flags(owner, account_id, folder, uid, "\\Answered", True)
_clear_done_response_tags(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
return {"success": True}
@@ -5489,7 +5643,7 @@ def setup_email_routes():
conn.select(_q(folder))
if not _store_email_flag(conn, uid, "\\Answered", add=False):
return {"success": False, "error": "Email not found"}
_email_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
_index_update_flags(owner, account_id, folder, uid, "\\Answered", False)
_invalidate_list_cache(account_id, folder)
return {"success": True}
except Exception as e:
@@ -6156,7 +6310,7 @@ def setup_email_routes():
try:
st, _sel = imap.select(_q(_source_folder), readonly=False)
if st == "OK" and _store_email_flag(imap, _source_uid, "\\Answered", add=True):
_email_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
_index_update_flags(owner, _account_id, _source_folder, _source_uid, "\\Answered", True)
_clear_done_response_tags(owner, _account_id, _source_folder, _source_uid)
_invalidate_list_cache(_account_id, _source_folder)
logger.info(f"Marked source UID {_source_uid} as \\Answered in {_source_folder}")
@@ -6706,7 +6860,7 @@ def setup_email_routes():
await queue.put({'type': 'result', **result})
task = asyncio.create_task(run())
try:
while True:
while not _shutdown.is_shutting_down():
item = await queue.get()
yield 'data: ' + json.dumps(item) + '\n\n'
if item.get('type') == 'result':
@@ -7139,6 +7293,12 @@ def setup_email_routes():
if account_id:
_assert_owns_account(account_id, owner)
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
if data.get(field):
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
if host_error:
return {"ok": False, "error": host_error}
# Non-reply automation flags stay global. Away/auto-reply settings are
# account-scoped when an account_id is supplied by the Email Settings UI.
settings = _load_settings()
@@ -7279,6 +7439,11 @@ def setup_email_routes():
smtp_port, port_err = _coerce_port(data.get("smtp_port"), 465)
if port_err:
return {"ok": False, "error": port_err}
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
if data.get(field):
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
if host_error:
return {"ok": False, "error": host_error}
db = SessionLocal()
try:
_lock_email_account_owner_mutation(db, owner)
@@ -7324,6 +7489,11 @@ def setup_email_routes():
"""Update an email account. Passwords only overwrite if non-empty."""
# Path param account_id — dep validated via Query, re-check the path-param value.
_assert_owns_account(account_id, owner)
for field, label in (("imap_host", "IMAP"), ("smtp_host", "SMTP")):
if data.get(field):
host_error = await run_in_threadpool(_check_mail_host, data[field], owner, label)
if host_error:
return {"ok": False, "error": host_error}
from core.database import SessionLocal, EmailAccount
db = SessionLocal()
try:
+3 -2
View File
@@ -19,9 +19,10 @@ def _split_family_token(token):
def _derive_family(filename):
"""Derive a font-family name from a filename like 'JetBrainsMono-Regular.woff2' → 'JetBrains Mono'."""
name = os.path.splitext(filename)[0]
# Strip common weight/style suffixes
# Strip weight/style suffixes, including BoldItalic and Bold-Italic.
name = re.sub(
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)$',
r'[-_ ]?(Thin|ExtraLight|UltraLight|Light|Regular|Medium|SemiBold|DemiBold|Bold|ExtraBold|UltraBold|Black|Heavy|Italic|Oblique|Variable|VF)'
r'(?:[-_ ]?(?:Italic|Oblique))?$',
'', name, flags=re.IGNORECASE
)
# Replace dashes/underscores with spaces
File diff suppressed because it is too large Load Diff
+23
View File
@@ -471,10 +471,16 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
_verify_session_owner(request, session_id)
try:
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "Request body must be a JSON object")
role = body.get("role", "assistant")
content = body.get("content", "")
if role not in ("user", "assistant"):
raise HTTPException(400, "role must be 'user' or 'assistant'")
if not content:
raise HTTPException(400, "content is required")
if not isinstance(content, str):
raise HTTPException(400, "content must be a string")
metadata = sanitize_client_message_metadata(body.get("metadata"))
_reserve_message_uploads(request, content, metadata)
msg = ChatMessage(role=role, content=content, metadata=metadata)
@@ -825,6 +831,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
try:
body = await request.json()
keep_count = body.get("keep_count", 0)
# A message id is exact; keep_count from the client counts only the
# messages loaded in the page. before_msg_id forks before that
# message, through_msg_id includes it.
before_msg_id = str(body.get("before_msg_id") or "").strip()
through_msg_id = str(body.get("through_msg_id") or "").strip()
if before_msg_id or through_msg_id:
db = SessionLocal()
try:
all_db_messages = db.query(DbChatMessage).filter(
DbChatMessage.session_id == session_id
).order_by(DbChatMessage.timestamp).all()
finally:
db.close()
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
if resolved is None:
raise HTTPException(404, "Message not found")
keep_count = resolved + (0 if before_msg_id else 1)
# Get the source session. keep_count indexes into source.history,
# so this must go through get_session — reading the cache directly
+7 -1
View File
@@ -5,6 +5,7 @@ import os
import uuid
import urllib.parse
import html
import secrets
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, Request
from fastapi.responses import RedirectResponse, HTMLResponse
@@ -12,7 +13,7 @@ import logging
import httpx
from core.database import McpServer, SessionLocal
from core.middleware import require_admin
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN, require_admin
from src.constants import DATA_DIR, MCP_OAUTH_DIR
from src.mcp_manager import McpManager
@@ -424,6 +425,11 @@ def setup_mcp_routes(mcp_manager: McpManager):
Expects JSON body: {"disabled": ["tool_name_1", "tool_name_2"]}
"""
require_admin(request)
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
# Same boundary as POST /api/tools: an agent turn must not be able
# to re-enable MCP tools an admin switched off.
raise HTTPException(403, "Change MCP tool policy in Settings > Integrations, not from chat")
db = SessionLocal()
try:
srv = db.query(McpServer).filter(McpServer.id == server_id).first()
+59 -11
View File
@@ -7,6 +7,7 @@ import json
import hashlib
import ipaddress
import socket
import secrets
import time as _time
import logging
import threading
@@ -417,6 +418,7 @@ _HOST_TO_CURATED = (
("nvidia.com", "nvidia"),
("openrouter.ai", "openrouter"),
("ollama.com", "ollama"),
("conifer.build", "openrouter"),
)
@@ -473,7 +475,7 @@ def _truthy(value: str | None) -> bool:
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
_REFRESH_MODES = {"auto", "manual", "disabled"}
_MODEL_TOOL_MODES = {"none", "compact", "full"}
_MODEL_TOOL_MODES = {"none", "compact", "full", "spec"}
_MODEL_TOOL_MODE_ALIASES = {
"regular": "full",
"odysseus_compact": "compact",
@@ -553,10 +555,10 @@ def _endpoint_refresh_timeout(ep: Any, category: str) -> float:
val = 0
if val > 0:
return float(max(1, min(60, val)))
# llama.cpp and other local OpenAI-compatible servers can block briefly
# while warming/loading. A 2s local timeout makes working endpoints flicker
# offline before /v1/models is ready.
return 10.0 if category == "local" else 2.0
# Keep the background refresh short so a single dead endpoint cannot stall
# the model picker. A timed-out refresh never clears a non-empty model
# cache, so a briefly-warming local server is simply retried next round.
return 2.0
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
@@ -658,7 +660,7 @@ _NON_CHAT_CONTAINS = (
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
"topic-control", "calibration",
"ai-synthetic-video", "cosmos-reason2",
"bge", "llama-guard",
"bge", "llama-guard", "-embed", "rerank",
)
_NON_CHAT_EXACT_PREFIXES = (
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
@@ -1511,6 +1513,9 @@ def setup_model_routes(model_discovery):
_refresh_inflight = {"v": False, "done": None} # coarse single-flight guard
_REFRESH_FAILURE_BASE = 300.0
_REFRESH_FAILURE_MAX = 3600.0
# Even a user-triggered refresh must not re-probe an endpoint that just
# failed/timed out on every click; keep a short cooldown.
_REFRESH_FAILURE_MIN = 60.0
def _refresh_key(base: str, api_key: Optional[str], provider_auth_id: Optional[str] = None) -> str:
# Session-backed endpoints carry no static key; include their auth id
@@ -1558,14 +1563,20 @@ def setup_model_routes(model_discovery):
if mode in ("manual", "disabled") and not force:
return False, info
fails = int(state.get("fail_count") or 0)
if fails and not force:
if fails:
last_failure = float(state.get("last_failure") or 0.0)
empty_local = (
not cached
and category == "local"
and str(getattr(ep, "id", "") or "").startswith("local-")
)
if now - last_failure < _failure_delay(fails, empty_local=empty_local):
delay = _failure_delay(fails, empty_local=empty_local)
if force:
# A user-triggered refresh gets a short cooldown instead of the
# long background backoff, but still skips a just-failed
# endpoint so repeated clicks cannot stall the picker.
delay = min(delay, _REFRESH_FAILURE_MIN)
if now - last_failure < delay:
return False, info
if cached and not force:
interval = _endpoint_refresh_interval(ep, category)
@@ -2836,11 +2847,12 @@ def setup_model_routes(model_discovery):
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
base = _normalize_base(ep.base_url)
chat_url = build_chat_url(base)
if model and not _is_chat_model(model):
model = ""
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
try:
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
if visible:
model = visible[0]
model = next((mid for mid in visible if _is_chat_model(mid)), "")
except Exception:
pass
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
@@ -3033,19 +3045,43 @@ def setup_model_routes(model_discovery):
return {"dependents": _settings_using_endpoint(ep_id)}
@router.delete("/model-endpoints/{ep_id}")
def delete_model_endpoint(ep_id: str, request: Request):
def delete_model_endpoint(
ep_id: str,
request: Request,
expected_cookbook_serve_session_id: Optional[str] = None,
):
require_admin(request)
db = SessionLocal()
try:
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
if not ep or not _chatgpt_endpoint_visible(ep, request):
raise HTTPException(404, "Endpoint not found")
if expected_cookbook_serve_session_id is not None:
# The DELETE itself checks ownership. An old serve must never
# mutate the settings of an endpoint reused by another serve.
if (
not ep_id.startswith(("local-", "img-"))
or not expected_cookbook_serve_session_id
):
raise HTTPException(409, "Cookbook endpoint ownership mismatch")
deleted = (
db.query(ModelEndpoint)
.filter(
ModelEndpoint.id == ep_id,
ModelEndpoint.cookbook_serve_session_id
== expected_cookbook_serve_session_id,
)
.delete(synchronize_session=False)
)
if not deleted:
raise HTTPException(409, "Cookbook endpoint ownership changed")
# Clean up any settings that reference this endpoint
cleared = _clear_settings_for_endpoint(ep_id)
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
auth_id = getattr(ep, "provider_auth_id", None)
if expected_cookbook_serve_session_id is None:
db.delete(ep)
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
db.commit()
@@ -3082,6 +3118,12 @@ def setup_model_routes(model_discovery):
def update_tools(body: ToolsUpdate, request: Request):
"""Update which tools are disabled."""
require_admin(request)
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
internal_token = request.headers.get(INTERNAL_TOOL_HEADER)
if internal_token and secrets.compare_digest(internal_token, INTERNAL_TOOL_TOKEN):
# Owner impersonation can carry an admin username, so check the
# authenticated transport rather than request.state.current_user.
raise HTTPException(403, "Change tool policy in Settings > Agent Tools, not from chat")
settings = _load_settings()
settings["disabled_tools"] = body.disabled
_save_settings(settings)
@@ -3089,4 +3131,10 @@ def setup_model_routes(model_discovery):
router._should_refresh_endpoint = _should_refresh_endpoint
router._search_endpoint_catalog = search_endpoint_catalog
# Exposed for tests: the in-memory backoff state, its key function, and the
# background refresh driver (probes endpoints in parallel with the short
# per-endpoint timeout).
router._refresh_state = _refresh_state
router._refresh_key = _refresh_key
router._refresh_caches_bg = _refresh_caches_bg
return router
+12 -9
View File
@@ -728,32 +728,35 @@ def setup_note_routes(task_scheduler=None, upload_handler=None):
)
if body.title is not None:
note.title = body.title
if body.content is not None:
# Omitted fields preserve their values; explicit null clears the
# nullable fields used by the editor's removal controls.
supplied = body.model_fields_set
if "content" in supplied:
note.content = body.content
if body.items is not None:
note.items = json.dumps(body.items)
if "items" in supplied:
note.items = json.dumps(body.items) if body.items is not None else None
flag_modified(note, "items")
if body.note_type is not None:
note.note_type = body.note_type
if body.color is not None:
if "color" in supplied:
note.color = body.color
if body.label is not None:
if "label" in supplied:
note.label = body.label
if body.pinned is not None:
note.pinned = body.pinned
if body.archived is not None:
note.archived = body.archived
if body.due_date is not None:
if "due_date" in supplied:
note.due_date = body.due_date
if body.image_url is not None:
if "image_url" in supplied:
note.image_url = body.image_url
if body.gallery_id is not None:
if "gallery_id" in supplied:
note.gallery_id = body.gallery_id
if body.repeat is not None:
note.repeat = body.repeat
if body.sort_order is not None:
note.sort_order = body.sort_order
if body.agent_session_id is not None:
if "agent_session_id" in supplied:
note.agent_session_id = body.agent_session_id
db.commit()
+6 -2
View File
@@ -118,8 +118,12 @@ def setup_prefs_routes():
async def set_pref(request: Request, key: str, body: dict):
user = get_current_user(request)
prefs = _load_for_user(user)
prefs[key] = body.get("value")
val = body.get("value")
if val is None:
prefs.pop(key, None)
else:
prefs[key] = val
_save_for_user(user, prefs)
return {"key": key, "value": prefs[key]}
return {"key": key, "value": prefs.get(key)}
return router
+15 -17
View File
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
from src.auth_helpers import _auth_disabled, get_current_user
from src.owner_identity import REQUEST_SENTINEL_OWNERS
from src.constants import DEEP_RESEARCH_DIR
from src import shutdown as _shutdown
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
@@ -182,6 +183,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
"""
from src.endpoint_resolver import (
_endpoint_enabled_models,
build_chat_url,
build_headers,
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
@@ -196,7 +198,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
ep_model = (model or "").strip()
if not ep_model:
try:
models = json.loads(ep.cached_models) if ep.cached_models else []
models = _endpoint_enabled_models(ep)
if models:
ep_model = _first_chat_model(models)
except Exception:
@@ -285,11 +287,17 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
async def research_status(session_id: str, request: Request):
user = _require_user(request)
_validate_session_id(session_id)
# No in-flight or persisted research for this session is a normal state
# (most sessions have none), not an error. Return HTTP 200 with an
# explicit empty status so loading a session does not log a spurious
# 404; every client treats a non-"running" status as idle anyway.
# Research owned by ANOTHER user answers identically, so the response
# does not reveal whether that research exists.
if not _owns_in_memory(session_id, user):
raise HTTPException(404, "No research found for this session")
return {"status": "none"}
status = research_handler.get_status(session_id)
if status is None:
raise HTTPException(404, "No research found for this session")
return {"status": "none"}
try:
source_state = research_handler.get_source_state(session_id)
if isinstance(source_state, str) and source_state:
@@ -657,7 +665,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
raise HTTPException(404, "No research found for this session")
async def _generate():
last_payload = None
while True:
while not _shutdown.is_shutting_down():
status = research_handler.get_status(session_id)
if status is None:
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
@@ -817,23 +825,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
if not ep_url or not ep_model:
# Last resort: this user's enabled endpoint, plus legacy shared rows.
from src.database import SessionLocal
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
db = SessionLocal()
try:
ep = _owned_enabled_endpoint(db, user)
if ep:
base = normalize_base(ep.base_url)
fallback_url = build_chat_url(base)
fallback_headers = build_headers(ep.api_key, base)
fallback_model = ""
if ep.cached_models:
try:
models = json.loads(ep.cached_models)
if models:
fallback_model = _first_chat_model(models)
except Exception:
pass
_merge(fallback_url, fallback_model, fallback_headers)
resolved = _resolve_endpoint_runtime(ep, owner=user)
if resolved:
_merge(*resolved)
finally:
db.close()
+14 -44
View File
@@ -1315,32 +1315,22 @@ def setup_session_routes(
def auto_sort_sessions(request: Request, skip_llm: bool = False):
"""Use AI to categorize all sessions into folders.
Phase 1 deletes empty/throwaway sessions and Phase 2 asks the LLM
Phase 1 deletes empty/Incognito sessions and Phase 2 asks the LLM
to assign folders. When `skip_llm=true` the endpoint returns
after Phase 1 — used by the "Tidy (no AI)" UI affordance so
users can clean junk without spending tokens.
"""
from src.llm_core import llm_call
from src.llm_core import llm_call, llm_call_with_fallback
from src.utility_effort import effort_for_call
user = effective_user(request)
single_user_mode = not user and _auth_disabled()
user_sessions = session_manager.get_sessions_for_user(user)
# Delete empty and throwaway sessions before sorting
# Saved conversations are never disposable based on their title or prompt.
from core.database import ChatMessage as DbMsg
db = SessionLocal()
deleted_empty = 0
deleted_throwaway = 0
# Names that indicate a throwaway/test session (case-insensitive exact or prefix match)
_THROWAWAY_NAMES = {
"test", "testing", "asdf", "asd", "hello", "hi", "hey",
"yo", "sup", "hola", "hii", "hiii", "heyo",
"foo", "bar", "baz", "tmp", "temp", "scratch", "untitled",
"new chat", "delete", "remove", "junk", "trash", "xxx",
"abc", "qwerty", "blah", "stuff", "whatever", "idk",
"ok", "lol", "bruh", "hmm", "hm", "meh",
}
_THROWAWAY_MAX_MESSAGES = 4 # only delete if <= this many messages
try:
rows_q = db.query(DbSession).filter(DbSession.archived == False)
if user:
@@ -1349,15 +1339,11 @@ def setup_session_routes(
rows_q = rows_q.filter(DbSession.owner == user)
rows = rows_q.limit(2000).all()
folder_map = {r.id: r.folder for r in rows}
# Precompute per-session message counts in TWO aggregate queries
# Precompute per-session message counts in one aggregate query
# instead of 1–3 queries PER session — with many chats the per-row
# loop was doing thousands of round-trips and blowing the timeout.
from sqlalchemy import func as _sa_func
_counts = dict(db.query(DbMsg.session_id, _sa_func.count(DbMsg.id)).group_by(DbMsg.session_id).all())
_asst_counts = dict(
db.query(DbMsg.session_id, _sa_func.count(DbMsg.id))
.filter(DbMsg.role == "assistant").group_by(DbMsg.session_id).all()
)
cleanup_now = utcnow_naive()
for row in rows:
# Never delete important sessions
@@ -1367,9 +1353,10 @@ def setup_session_routes(
if (row.name or "").strip() == "Incognito":
should_delete = True
deleted_throwaway += 1
db.delete(row)
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
else:
db.delete(row)
continue
if is_session_recently_active(row, now=cleanup_now):
continue
@@ -1378,31 +1365,11 @@ def setup_session_routes(
if msg_count == 0:
should_delete = True
deleted_empty += 1
elif msg_count <= _THROWAWAY_MAX_MESSAGES:
name = (row.name or "").strip().lower()
# Check first user message content (AI renames sessions, so
# "hi" becomes "Casual Greeting Exchange" — name alone won't match)
first_msg = db.query(DbMsg.content).filter(
DbMsg.session_id == row.id, DbMsg.role == "user"
).order_by(DbMsg.timestamp).first()
first_text = (first_msg[0] or "").strip().lower() if first_msg else ""
# Count assistant messages — if user sent something but AI never replied, it's dead
assistant_count = _asst_counts.get(row.id, 0)
if name in _THROWAWAY_NAMES or name.startswith("chat:") or first_text in _THROWAWAY_NAMES:
should_delete = True
deleted_throwaway += 1
# Single user message with no AI response = dead session
elif msg_count == 1 and assistant_count == 0:
should_delete = True
deleted_throwaway += 1
# Short phrase (1-3 words) with no real AI conversation (<=2 msgs)
elif msg_count <= 2 and first_text and len(first_text.split()) <= 3 and len(first_text) <= 40:
should_delete = True
deleted_throwaway += 1
if should_delete:
db.delete(row)
if hasattr(session_manager, 'delete_session'):
session_manager.delete_session(row.id)
else:
db.delete(row)
if deleted_empty or deleted_throwaway:
db.commit()
logger.info(f"Auto-sort: deleted {deleted_empty} empty + {deleted_throwaway} throwaway sessions")
@@ -1467,7 +1434,7 @@ def setup_session_routes(
return {"status": "skipped", "reason": "No unfiled sessions to sort"}
# Pick an endpoint — prefer admin-configured task endpoint
from src.task_endpoint import resolve_task_endpoint
from src.task_endpoint import resolve_task_endpoint, resolve_task_candidates
url, model, headers = resolve_task_endpoint(owner=user)
if not url:
url, model, headers = _pick_endpoint_for_sort(owner=user)
@@ -1493,8 +1460,11 @@ def setup_session_routes(
# 16384 (was 4096): with many chats the folder JSON is large, and a
# reasoning model spends tokens thinking first — 4096 truncated the
# JSON mid-output, so it never parsed ("invalid JSON for auto-sort").
raw = llm_call(url, model, [{"role": "user", "content": prompt}],
temperature=0.3, max_tokens=16384, headers=headers, timeout=120,
raw = llm_call_with_fallback(
resolve_task_candidates(fallback_url=url, fallback_model=model,
fallback_headers=headers, owner=user),
[{"role": "user", "content": prompt}],
temperature=0.3, max_tokens=16384, timeout=120,
reasoning_effort=effort_for_call(url, model, user))
logger.info(f"Auto-sort raw response ({len(raw)} chars): {raw[:300]}")
# Extract JSON from response — handle markdown fences, leading text,
+4 -3
View File
@@ -27,6 +27,7 @@ from src.host_docker_access import (
from src.optional_deps import prepare_optional_dependency_import
from src.auth_helpers import _auth_disabled
from src import process_lifecycle
from src import shutdown as _shutdown
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
# on Windows, so importing them unconditionally crashed app startup there
@@ -1025,7 +1026,7 @@ async def _generate_tmux(cmd: str, request: Request):
lines_sent = 0
exit_code = None
while True:
while not _shutdown.is_shutting_down():
# Check client disconnect
if await request.is_disconnected():
# tmux keeps running — that's the whole point
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
lines_sent = 0
exit_code = None
while True:
while not _shutdown.is_shutting_down():
if await request.is_disconnected():
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
return
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
"""Read chunks, split on \\n or \\r for progress bar support."""
try:
buf = b""
while True:
while not _shutdown.is_shutting_down():
chunk = await stream.read(4096)
if not chunk:
if buf:
+5
View File
@@ -15,6 +15,7 @@ import httpx
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from services.memory.skill_format import slugify
from services.memory.skills import SkillsManager
from src.auth_helpers import get_current_user
from services.skills import evaluation as _evaluation
@@ -836,6 +837,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
updates = body.dict(exclude_none=True)
if not updates:
return {"ok": True}
if "name" in updates:
new_name = slugify(updates["name"] or match.get("name"))
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
raise HTTPException(409, f"A skill named '{new_name}' already exists")
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
if not ok:
raise HTTPException(404, "Skill not found")
+12 -17
View File
@@ -20,7 +20,7 @@ from src.task_action_policy import (
is_admin_only_task_action,
owner_has_admin_task_privileges,
)
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
from src.task_scheduler import compute_next_run, is_valid_cron, HOUSEKEEPING_DEFAULTS
from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__)
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
try:
from core.database import ModelEndpoint
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
from src.auth_helpers import owner_filter
from src.endpoint_resolver import _endpoint_enabled_models
eps = owner_filter(
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
ModelEndpoint, getattr(task, "owner", None),
).all()
for ep in eps:
cached = []
if ep.cached_models:
try:
cached = json.loads(ep.cached_models) or []
except Exception:
cached = []
if model in cached:
if model in _endpoint_enabled_models(ep):
return ep.base_url or ""
except Exception:
pass
@@ -340,6 +340,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
max_tokens=20,
headers=headers,
timeout=15,
thinking_mode="off",
)
title = result.strip().strip('"\'').strip()
return title[:60] if title else prompt[:50].strip()
@@ -475,10 +476,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
raise HTTPException(400, "Cron expression is required for cron schedule")
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
if not is_valid_cron(req.cron_expression):
raise HTTPException(400, "Invalid cron expression")
if req.trigger_type == "event" and not req.trigger_event:
raise HTTPException(400, "Event name is required for event-triggered tasks")
@@ -788,10 +786,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task.character_id = req.character_id or None
if req.cron_expression is not None:
if req.cron_expression:
try:
from croniter import croniter
croniter(req.cron_expression)
except Exception:
if not is_valid_cron(req.cron_expression):
raise HTTPException(400, "Invalid cron expression")
task.cron_expression = req.cron_expression or None
+73 -27
View File
@@ -25,11 +25,12 @@ from src.auth_helpers import effective_user, is_delegated_credential
from src.owner_identity import auth_disabled, is_request_sentinel_owner
from src.attachment_refs import attachment_refs_from_metadata
from src.constants import GENERATED_IMAGES_DIR
from src.path_confinement import is_inside
from src.path_confinement import PathEscape, canonical_root, confine, is_inside
from src.upload_handler import (
UploadCleanupSafetyError,
count_recent_uploads,
extract_upload_ids,
secure_filename,
)
logger = logging.getLogger(__name__)
@@ -153,30 +154,47 @@ def setup_upload_routes(upload_handler):
def _upload_root() -> str:
from src.constants import UPLOAD_DIR
return os.path.realpath(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
return canonical_root(getattr(upload_handler, "upload_dir", UPLOAD_DIR))
def _path_inside_upload_dir(path: str) -> bool:
return is_inside(_upload_root(), path)
def _resolve_upload_path(file_id: str) -> str:
from src.constants import UPLOAD_DIR
upload_root = getattr(upload_handler, "upload_dir", UPLOAD_DIR)
direct = os.path.join(upload_root, file_id)
safe_id = secure_filename(os.path.basename(file_id))
if not safe_id:
raise HTTPException(400, "Invalid file ID")
root = _upload_root()
direct = os.path.normpath(os.path.join(root, safe_id))
if os.path.lexists(direct):
if not _path_inside_upload_dir(direct):
try:
canonical_direct = confine(root, direct, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(direct):
return direct
if os.path.isfile(canonical_direct):
return canonical_direct
raise HTTPException(404, "File not found")
for root, _dirs, files in os.walk(upload_root, followlinks=False):
if file_id not in files:
continue
path = os.path.join(root, file_id)
if not _path_inside_upload_dir(path):
info = _load_upload_info(safe_id)
stored_path = (info or {}).get("path")
if stored_path:
try:
canonical_stored = confine(root, stored_path, allow_root=False)
if os.path.isfile(canonical_stored):
return canonical_stored
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(path):
return path
for walk_root, _dirs, files in os.walk(root, followlinks=False):
if safe_id not in files:
continue
cand = os.path.normpath(os.path.join(walk_root, safe_id))
try:
canonical_cand = confine(root, cand, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if os.path.isfile(canonical_cand):
return canonical_cand
raise HTTPException(404, "File not found")
raise HTTPException(404, "File not found")
@@ -435,35 +453,62 @@ def setup_upload_routes(upload_handler):
logger.error(f"Failed to get upload stats: {e}")
raise HTTPException(500, "Failed to get upload statistics")
@router.get("/{file_id}")
@router.api_route("/{file_id}", methods=["GET", "HEAD"])
async def download_file(request: Request, file_id: str, thumb: int = 0):
"""Serve an uploaded file by its ID. `?thumb=1` returns a small cached
JPEG thumbnail for images (used by chat attachment previews) so the
client isn't downloading the full-resolution photo just to show it tiny."""
"""Serve an uploaded file by its ID or probe via HEAD. `?thumb=1` returns
a small cached JPEG thumbnail for images (used by chat attachment previews)
so the client isn't downloading the full-resolution photo just to show it tiny."""
if not upload_handler.validate_upload_id(file_id):
raise HTTPException(400, "Invalid file ID")
safe_file_id = secure_filename(os.path.basename(file_id))
import mimetypes as _mt
# Look up original filename and owner from uploads.json
original_name = file_id
original_name = safe_file_id
# _load_upload_index() tolerates a missing/corrupt uploads.json (it falls
# back to the .bak sibling, then to {}), so a truncated DB degrades to
# "no metadata" instead of a 500 from an unhandled JSONDecodeError.
db = upload_handler._load_upload_index()
info = next((fi for fi in db.values() if fi.get("id") == file_id), None)
info = next((fi for fi in db.values() if fi.get("id") == safe_file_id), None)
if info:
original_name = info.get("name", file_id)
original_name = info.get("name", safe_file_id)
file_owner = info.get("owner") if info else None
_verify_upload_access(request, file_owner)
path = _resolve_upload_path(file_id)
upload_root = _upload_root()
path = _resolve_upload_path(safe_file_id)
try:
path = confine(upload_root, path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not path.startswith(upload_root + os.sep) or not _path_inside_upload_dir(path):
raise HTTPException(403, "Access denied")
mime = (info or {}).get("mime") or _mt.guess_type(path)[0] or "application/octet-stream"
from fastapi.responses import FileResponse
# Downscaled thumbnail for image previews — generated once and cached.
# Skip generation during HEAD probe to avoid loading/processing whole image.
if thumb and mime.startswith("image/"):
thumb_dir = os.path.normpath(os.path.join(upload_root, ".thumbs"))
thumb_path = os.path.normpath(os.path.join(thumb_dir, safe_file_id + ".jpg"))
try:
thumb_path = confine(upload_root, thumb_path, allow_root=False)
except (PathEscape, ValueError):
raise HTTPException(403, "Access denied")
if not thumb_path.startswith(thumb_dir + os.sep) or not _path_inside_upload_dir(thumb_path):
raise HTTPException(403, "Access denied")
if request.method == "HEAD":
if (os.path.isfile(thumb_path)
and os.path.getmtime(thumb_path) >= os.path.getmtime(path)):
return FileResponse(thumb_path, media_type="image/jpeg", headers=UPLOAD_RESPONSE_HEADERS)
# GET may generate a JPEG or fall back to the original on failure.
# Neither representation is known without decoding the image. HEAD
# must not advertise the original/stale file's headers as a thumb.
from fastapi.responses import Response
response = Response(status_code=200, headers=UPLOAD_RESPONSE_HEADERS)
del response.headers["content-length"]
return response
else:
try:
from PIL import Image, ImageOps
thumb_dir = os.path.join(_upload_root(), ".thumbs")
os.makedirs(thumb_dir, exist_ok=True)
thumb_path = os.path.join(thumb_dir, file_id + ".jpg")
if (not os.path.exists(thumb_path)
or os.path.getmtime(thumb_path) < os.path.getmtime(path)):
im = Image.open(path)
@@ -496,9 +541,10 @@ def setup_upload_routes(upload_handler):
return next((fi for fi in db.values() if fi.get("id") == file_id), None)
def _vision_cache_path(file_id: str) -> str:
cache_dir = os.path.join(_upload_root(), ".vision")
safe_id = secure_filename(os.path.basename(file_id))
cache_dir = os.path.normpath(os.path.join(_upload_root(), ".vision"))
os.makedirs(cache_dir, exist_ok=True)
return os.path.join(cache_dir, file_id + ".txt")
return os.path.normpath(os.path.join(cache_dir, safe_id + ".txt"))
def _sync_gallery_caption_for_upload(info: dict | None, owner: str | None, text: str) -> None:
"""Copy upload OCR/vision text onto the promoted gallery image row."""
File diff suppressed because it is too large Load Diff
+122
View File
@@ -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())
+91
View File
@@ -492,6 +492,25 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
"tools that execute code, mutate state or cause external side effects need "
"a separate approval. Set to 0 to opt out.",
),
"ODYSSEUS_LOCAL_MCP_READ_TOOLS": (
"Agent loop and tool execution", USER,
"Security-relevant. Comma-separated exact MCP read-tool names "
"on configured stdio MCP servers. Only stdio servers qualify. "
"Declarations do not override built-in tool effects. Output remains "
"untrusted to prevent privilege escalation, but allows continuation to "
"declared write tools on the same stdio server without external taint. "
"Stdio transport does not guarantee network isolation. Automatic "
"same-store writes carry residual prompt-injection risk.",
),
"ODYSSEUS_LOCAL_MCP_WRITE_TOOLS": (
"Agent loop and tool execution", USER,
"Security-relevant. Comma-separated exact MCP write-tool names "
"on configured stdio MCP servers. Only stdio servers qualify. "
"Declarations do not override built-in tool effects. Allowed to execute "
"after declared local reads on the same stdio server, but blocked once "
"external untrusted context enters the run. Authorization must still "
"originate from the trusted request grant.",
),
"ODYSSEUS_MCP_ALLOWED_COMMANDS": (
"Agent loop and tool execution", USER,
"Security-relevant. Comma-separated allowlist of MCP launcher basenames the "
@@ -914,6 +933,78 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
"Overrides the source commit reported for runtime provenance, for builds "
"that ship without a git directory.",
),
# -- Photo editor and image operations -----------------------------------
"ODYSSEUS_EDITOR_AGENT_BASES": (
"Model routing and providers", USER,
"Comma-separated base URLs for the image-editor agent backends; a single "
"base URL in the model endpoint config wins over this list.",
),
"ODYSSEUS_ALLOW_LOCAL_IMAGE_MODELS": (
"Model routing and providers", USER,
"Set to 1, true, yes or on to allow local image-generation and editing "
"backends to be reachable as image providers.",
),
"ODYSSEUS_IMAGE_OPS_BASE": (
"Model routing and providers", USER,
"Base URL of the image-operations backend (inpaint, outpainting and style "
"transfer); a trailing slash is stripped before use.",
),
"ODYSSEUS_IMAGE_EDIT_MAX_SIDE": (
"Upload and media limits", USER,
"Maximum pixel side (width or height) an edited image is downscaled to "
"before it is sent to the image backend.",
),
"ODYSSEUS_IMAGE_EDIT_MAX_STEPS": (
"Upload and media limits", USER,
"Maximum number of iterative edit steps the image backend may perform in "
"a single request.",
),
"ODYSSEUS_IMAGE_OUTPAINT_TIMEOUT": (
"Upload and media limits", USER,
"Seconds the outpainting proxy waits for the image backend before giving "
"up on a single request.",
),
"ODYSSEUS_JEFF_": ("Agent loop and tool execution", INTERNAL, "Prefix for per-job decision adapter opt-ins; each job is disabled by default."),
"ODYSSEUS_JEFF_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Timeout in seconds for a decision adapter request."),
"ODYSSEUS_JEFF_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional decision adapter service."),
"ODYSSEUS_PRIVATE_BROWSER_LEGACY": ("Agent loop and tool execution", INTERNAL, "Explicit opt-in to the legacy browser compatibility transport; disabled by default."),
"ODYSSEUS_SPEC_BROWSER_FALLBACK": ("Agent loop and tool execution", INTERNAL, "Optional browser routing fallback: mcp, web, or 0 to disable."),
"ODYSSEUS_SPEC_MODE": ("Agent loop and tool execution", INTERNAL, "Spec routing mode for explicitly configured models, including strict and driver."),
"ODYSSEUS_SPEC_ROUTER_MIN_CONF": ("Agent loop and tool execution", INTERNAL, "Minimum tool-selection confidence accepted from the router."),
"ODYSSEUS_SPEC_ROUTER_MODEL": ("Agent loop and tool execution", INTERNAL, "Decision adapter model used for tool routing."),
"ODYSSEUS_SPEC_ROUTER_SECOND": ("Agent loop and tool execution", INTERNAL, "Minimum confidence for adding a secondary tool candidate."),
"ODYSSEUS_SPEC_ROUTER_TIMEOUT": ("Agent loop and tool execution", INTERNAL, "Time budget in seconds for a router decision."),
"ODYSSEUS_SPEC_ROUTER_URL": ("Agent loop and tool execution", INTERNAL, "Base URL of the optional Spec router; an empty URL disables requests."),
"ODYSSEUS_SPEC_THINK_MIN_P": ("Agent loop and tool execution", INTERNAL, "Minimum router confidence for enabling thinking."),
"ODYSSEUS_SPEC_THINK_MODEL": ("Agent loop and tool execution", INTERNAL, "Optional thinking decision adapter model, falling back to the tool router model."),
"ODYSSEUS_SPEC_THINK_QUESTION": ("Agent loop and tool execution", INTERNAL, "Thinking classifier question format: binary or kind5."),
"ODYSSEUS_SPEC_THINK_ROUTER": ("Agent loop and tool execution", INTERNAL, "Enable router thinking decisions for explicitly configured Spec models."),
"ODYSSEUS_SPEC_TOOL_FORMAT": ("Agent loop and tool execution", INTERNAL, "Use names for compact router tool signatures, otherwise include descriptions."),
"ODYSSEUS_SPEC_TRIM_PROMPT": ("Agent loop and tool execution", INTERNAL, "Enable prompt trimming for the selected Spec tool menu."),
"ODYSSEUS_VISION_MAX_SIDE": ("Upload and media limits", USER, "Maximum pixel side for image attachments sent directly to a vision model."),
"ODYSSEUS_VISION_MODELS": ("Model routing and providers", USER, "Comma-separated model-name fragments that explicitly support inline images."),
"ODYSSEUS_VISION_PROBE": ("Model routing and providers", USER, "Allow a cached vision-capability probe against local model endpoints."),
"ODYSSEUS_SPEC_DIRECT_MODE": (
"Agent loop and tool execution", INTERNAL,
"Controls how the spec router answers high-confidence direct-answer turns; "
"either tool_choice_none (default) or empty_menu.",
),
"ODYSSEUS_SPEC_DIRECT_NOTOOLS": (
"Agent loop and tool execution", INTERNAL,
"Set to 1, true, yes or on to enable the spec router's direct-answer "
"no-tools path for high-confidence answer_directly decisions.",
),
"ODYSSEUS_SPEC_DIRECT_MIN_P": (
"Agent loop and tool execution", INTERNAL,
"Minimum confidence probability a spec-router direct-answer decision must "
"reach before the no-tools path is taken.",
),
"ODYSSEUS_GRACEFUL_SHUTDOWN_TIMEOUT": (
"Deployment and first run", INTERNAL,
"Seconds the server waits for in-flight requests and background loops to "
"finish during a graceful shutdown.",
),
}
+12 -4
View File
@@ -27,6 +27,7 @@ from pathlib import Path
try:
from core.database import SessionLocal, Document, DocumentVersion
from src.session_search import escape_like_pattern
quiet_logs()
except ModuleNotFoundError as e:
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
@@ -139,10 +140,17 @@ def cmd_export(args):
def cmd_search(args):
db = SessionLocal()
try:
like = f"%{args.query}%"
rows = db.query(Document).filter(
(Document.title.ilike(like)) | (Document.current_content.ilike(like))
).order_by(Document.updated_at.desc()).limit(args.limit).all()
# Per-term AND search: a single `%foo bar%` LIKE only matched the
# exact phrase, so "machine learning" missed docs containing both
# words apart. LIKE wildcards are escaped per token.
q = db.query(Document)
for tok in args.query.split():
term = f"%{escape_like_pattern(tok)}%"
q = q.filter(
Document.title.ilike(term, escape="\\")
| Document.current_content.ilike(term, escape="\\")
)
rows = q.order_by(Document.updated_at.desc()).limit(args.limit).all()
emit([_serialize(d) for d in rows], args)
finally:
db.close()
+9 -2
View File
@@ -6,6 +6,7 @@ that are awkward at the shell). Filters images by tag, prompt, favorite,
album, and surfaces EXIF metadata.
odysseus-gallery list --limit 20 --favorites
odysseus-gallery list --tag "sunset,beach" # all tags, manual or AI
odysseus-gallery show IMAGE_ID
odysseus-gallery albums | jq -r '.[] | .name'
odysseus-gallery search "sunset" # matches prompt + tags
@@ -83,7 +84,13 @@ def cmd_list(args):
fail(f"no album named {args.album!r}")
q = q.filter(GalleryImage.album_id == al.id)
if args.tag:
q = q.filter(GalleryImage.tags.ilike(f"%{args.tag}%"))
for tag in (value.strip() for value in args.tag.split(",")):
if not tag:
continue
q = q.filter(
GalleryImage.tags.ilike(f"%{tag}%") |
GalleryImage.ai_tags.ilike(f"%{tag}%")
)
q = q.order_by(GalleryImage.created_at.desc()).limit(args.limit)
emit([_serialize_image(i) for i in q.all()], args)
finally:
@@ -159,7 +166,7 @@ def _build_parser():
pl.add_argument("--limit", type=int, default=50)
pl.add_argument("--favorites", action="store_true")
pl.add_argument("--album", help="filter by album name")
pl.add_argument("--tag", help="substring match against tags column")
pl.add_argument("--tag", help="comma-separated tags; each matches manual or AI tags by substring")
pl.set_defaults(func=cmd_list)
psh = sub.add_parser("show", parents=[common])
+12 -6
View File
@@ -43,7 +43,7 @@ import logging
import os
import re
import sys
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
# Anchor the path so the script works when invoked from anywhere.
@@ -136,7 +136,7 @@ def cmd_list(args) -> None:
emit([], args)
return
all_uids = data[0].split()
# Newest first, limit
# Keep a bounded arrival batch; order its decoded rows by Date below.
uids = list(reversed(all_uids))[: args.limit]
out = []
for uid in uids:
@@ -165,10 +165,15 @@ def cmd_list(args) -> None:
sender_name, sender_addr = parseaddr(sender)
date_raw = msg.get("Date", "")
try:
iso = parsedate_to_datetime(date_raw).isoformat() if date_raw else ""
parsed_date = parsedate_to_datetime(date_raw) if date_raw else None
iso = parsed_date.isoformat() if parsed_date else ""
if parsed_date is not None and parsed_date.tzinfo is None:
parsed_date = parsed_date.replace(tzinfo=timezone.utc)
date_epoch = parsed_date.timestamp() if parsed_date else float("-inf")
except Exception:
iso = ""
out.append({
date_epoch = float("-inf")
out.append((date_epoch, {
"uid": uid.decode(),
"date": iso,
"from_addr": sender_addr,
@@ -176,10 +181,11 @@ def cmd_list(args) -> None:
"subject": subject,
"is_read": "\\Seen" in flags,
"is_answered": "\\Answered" in flags,
})
}))
except Exception as e:
sys.stderr.write(f"warn: skipping uid {uid!r}: {e}\n")
emit(out, args)
out.sort(key=lambda item: item[0], reverse=True)
emit([row for _, row in out], args)
# ─── read ────────────────────────────────────────────────────────────
+336
View File
@@ -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()
+36 -6
View File
@@ -11,6 +11,38 @@ import time
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _stop_owned_server(server, identity):
"""Close only identity-bound descendants, including MCP's new sessions.
Capture before signalling the parent: an orphaned stderr holder can no
longer be attributed by ancestry. Never search by executable name or kill
the caller's process group.
"""
from src import process_lifecycle as lifecycle
descendants = (lifecycle.bind_descendants([identity.pid])
if identity.verdict() == lifecycle.OWNED else [])
if os.name == "nt":
if identity.verdict() == lifecycle.OWNED:
lifecycle.taskkill_tree(identity.pid)
else:
outcome = lifecycle.terminate_identities(
[*(entry.identity for entry in reversed(descendants)), identity],
steps=lifecycle.term_kill_steps(10),
)
if outcome.survivors or outcome.unverified:
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
try:
logs, _ = server.communicate(timeout=2)
except subprocess.TimeoutExpired as error:
# Do not turn an unidentified pipe holder into an unbounded wait.
server.stdout.close()
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
return logs
def main():
@@ -36,7 +68,10 @@ def main():
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
start_new_session=(os.name != "nt"),
)
from src.process_lifecycle import ProcessIdentity
server_identity = ProcessIdentity.capture(server.pid)
result = None
try:
origin = f"http://127.0.0.1:{port}"
@@ -62,12 +97,7 @@ def main():
print(result.stderr, file=sys.stderr)
return result.returncode
finally:
server.terminate()
try:
logs, _ = server.communicate(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
logs, _ = server.communicate()
logs = _stop_owned_server(server, server_identity)
if result is None or result.returncode:
print(logs[-8000:], file=sys.stderr)
+9
View File
@@ -660,6 +660,8 @@ def _detect_windows():
"gpu_vram_gb": d.get("gpu_vram_gb"),
"gpu_count": _as_int(d.get("gpu_count"), 0),
"backend": d.get("gpu_backend", "cpu_x86"),
"gpus": [],
"gpu_groups": [],
"homogeneous": True,
"gpu_error": None,
"platform": "windows",
@@ -822,12 +824,14 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
if result:
result = _attach_probe_context(result, host=host)
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
# SSH may work while the PowerShell hardware probe still fails.
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
@@ -852,6 +856,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
result = {"error": f"Cannot connect to {host}", "host": host}
_cache_by_host[cache_key] = (now, result)
_remote_host = None
_remote_port = None
_remote_platform = None
return result
available_ram = round(_get_available_ram_gb(), 1)
@@ -893,6 +898,9 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
"gpu_name": None,
"gpu_vram_gb": None,
"gpu_count": 0,
"gpus": [],
"gpu_groups": [],
"homogeneous": True,
"backend": backend,
# Set when nvidia-smi exists but failed (e.g. driver/library
# version mismatch) — lets the UI say "GPU driver error" instead
@@ -902,6 +910,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
result = _attach_probe_context(result, host=host)
_remote_host = None
_remote_port = None
_remote_platform = None
_cache_by_host[cache_key] = (now, result)
return result
+20 -2
View File
@@ -202,17 +202,35 @@ def _infer_use_case(repo_id, pipeline_tag):
return "general"
# HF collections mix in models no Cookbook LLM backend can serve (vision
# encoders, time-series forecasters, image/video generators). Image models
# have their own registry, so keep these out of the LLM catalog.
_NON_LLM_PIPELINE_TAGS = frozenset({
"time-series-forecasting",
"zero-shot-image-classification",
"image-classification",
"image-feature-extraction",
"text-to-image",
"image-to-video",
"text-to-video",
})
def _entry_from_collection_item(collection, item, source):
repo_id = item.get("id") or ""
if item.get("type") != "model" or not repo_id:
return None
if (item.get("pipeline_tag") or "").strip().lower() in _NON_LLM_PIPELINE_TAGS:
return None
repo_prefix = source.get("repo_prefix")
if repo_prefix and not repo_id.startswith(repo_prefix):
return None
raw_params = item.get("numParameters") or 0
active = None
# Read the name even when HF gives a total: it is the only source of the
# MoE active size (e.g. "-35B-A3B"), which the speed estimate depends on.
name_params, active = _parse_params_from_name(repo_id)
if not raw_params:
raw_params, active = _parse_params_from_name(repo_id)
raw_params = name_params
param_label, raw_params = _format_params(raw_params)
if not raw_params:
return None
+215 -9
View File
@@ -15,6 +15,7 @@ import json
import logging
import os
import re
import time
from typing import Optional
from src.memory import MemoryStoreUnreadable
@@ -78,11 +79,13 @@ EXTRACT_SYSTEM_PROMPT = (
"things the assistant said, one-off tasks, opinions on the current topic.\n\n"
"Rules:\n"
"- MAX 2 facts per conversation — only the most important\n"
"- Only extract facts the USER stated or clearly implied\n"
"- Only extract facts the USER explicitly stated about themselves\n"
"- Questions, requests to inspect records, and tool/assistant output are not evidence\n"
"- Each fact must be a single short sentence (under 15 words)\n"
"- If a fact is similar to something likely already known, skip it\n"
"- If nothing durable was revealed, return []\n\n"
"Return a JSON array of objects with 'text' and 'category' fields.\n"
"Return a JSON array of objects with 'text', 'category', and 'evidence' fields. "
"Evidence must be an exact quote from a user's own factual statement.\n"
"Categories: 'identity', 'preference', 'fact', 'contact', 'project', 'goal'\n\n"
"Return ONLY valid JSON, no markdown fences."
)
@@ -213,7 +216,10 @@ def _fallback_memory_candidates(messages) -> list[dict]:
if not text:
continue
m = re.search(r"\bmy name is\s+([A-Za-z][A-Za-z0-9 .'\-]{1,50})\b", text, re.I)
m = re.search(
r"\bmy name is\s+([A-Za-z][A-Za-z0-9 '\-]{0,49}?)"
r"(?=\s+(?:and|but|please)\b|[.!?;,\n]|$)", text, re.I,
)
if m:
name = _clean_memory_value(m.group(1), 50)
if name:
@@ -259,22 +265,183 @@ def _fallback_memory_candidates(messages) -> list[dict]:
return candidates[:2]
_USER_FACT_ASSERTION = re.compile(
r"\b(?:i(?:'m| am| live| work| use| have| prefer| like| love| hate| own| study| grew up)\b"
r"|my\s+[a-z][a-z -]{0,40}\s+(?:is|are|was|works|lives)\b"
r"|we\s+(?:are|live|work|have|own)\b"
r"|our\s+[a-z][a-z -]{0,40}\s+(?:is|are|was)\b)",
re.IGNORECASE,
)
def _has_user_fact_evidence(fact: dict, messages) -> bool:
"""Require an exact user assertion before persisting an LLM-inferred memory."""
evidence = fact.get("evidence")
if not isinstance(evidence, str):
return False
evidence = re.sub(r"\s+", " ", evidence).strip()
if len(evidence.split()) < 3 or not _USER_FACT_ASSERTION.match(evidence):
return False
for message in messages:
if _message_role(message) != "user":
continue
utterance = re.sub(r"\s+", " ", _message_text(message)).strip()
position = utterance.casefold().find(evidence.casefold())
if position < 0:
continue
# Judge the clause containing the quote, so a later question does not
# erase an earlier factual statement in the same user turn.
clause_start = max(
utterance.rfind(mark, 0, position) for mark in (".", "?", "!", ";")
) + 1
clause_end = min(
[end for mark in (".", "?", "!", ";")
if (end := utterance.find(mark, position)) >= 0] or [len(utterance)]
)
clause = utterance[clause_start:clause_end].strip()
# A question about one's records is not a statement of the answer.
if utterance[clause_end:clause_end + 1] == "?" or re.match(
r"^(?:who|what|where|when|why|how|do|does|did|can|could|would|"
r"show|list|find|search|tell|summarize|open|read)\b",
clause, re.IGNORECASE,
):
continue
return True
return False
_TOKEN_RE = re.compile(r"[a-z0-9]+")
# Function words carry no distinguishing content. Dropping them before
# comparing two facts keeps punctuation and harmless rephrasing from defeating
# the duplicate check.
_STOPWORDS = frozenset(
"a an the and or but if then of to in on at for with by from as is are was "
"were be been being do does did has have had it its this that these those "
"there here user users someone somebody they them their he she his her him i "
"we you your my our me us who whom whose what when where why how also "
"currently now still about into over under again more most very can could "
"may might must will would should".split()
)
# Words that frame a name/username statement; excluded when reading the value
# so the actual name tokens can be compared.
_NAME_TEMPLATE_WORDS = frozenset(
"name names named called call appears appear seems seem the user users his "
"her their it is are was were be been being username usernames login "
"account handle".split()
)
_NAME_KEYWORDS = frozenset({"name", "named", "called"})
_USERNAME_KEYWORDS = frozenset({"username", "login"})
def _content_tokens(text: str) -> set:
"""Lowercased content tokens with punctuation and stopwords removed."""
toks = _TOKEN_RE.findall((text or "").lower())
return {t for t in toks if t not in _STOPWORDS and len(t) >= 2}
def _name_slot(text: str):
"""Return (slot, value_tokens) when text states a name/username, else None.
Tokens are scanned so "username" is never mistaken for "name", and the
first keyword decides the slot (a sentence about a server *named* X that
also mentions a *username* Y is a 'name' fact, not a username fact)."""
words = _TOKEN_RE.findall((text or "").lower())
slot = None
for w in words:
if w in _NAME_KEYWORDS:
slot = "name"
break
if w in _USERNAME_KEYWORDS:
slot = "username"
break
if not slot:
return None
value = {
w for w in words
if w not in _NAME_TEMPLATE_WORDS and w not in _STOPWORDS and len(w) >= 2
}
return (slot, value) if value else None
def _name_slot_tokens(text: str):
slot = _name_slot(text)
return slot[1] if slot else None
def _name_vals_close(a: set, b: set) -> bool:
"""True when two name values state the same name: identical, or one is a
strict refinement of the other where the larger value is a clean name
(<=4 alphabetic tokens). Guards against merging a username into a fact
like 'a server named Odysseus ... with username pewds'."""
if a == b:
return True
if not a or not b:
return False
bigger = a if len(a) >= len(b) else b
clean = len(bigger) <= 4 and all(t.isalpha() for t in bigger)
return clean and (a <= b or b <= a)
def _is_text_duplicate(new_text: str, existing: list, threshold: float = 0.6) -> bool:
"""Check if new_text is too similar to any existing memory (Jaccard similarity)."""
new_tokens = set(new_text.lower().split())
"""Check if new_text states the same fact as an existing memory.
Three tests, cheapest first: symmetric Jaccard over content tokens,
containment (one fact is a strict refinement of the other), and a
name/username-slot match. The naive whole-string comparison this replaces
kept punctuation (so "Alex." != "alex") and missed short-vs-full names
("Felix" vs "FELIX ARVID ULF KJELLBERG" scored 0.43).
"""
new_tokens = _content_tokens(new_text)
if not new_tokens:
return False
new_slot = _name_slot(new_text)
for entry in _memory_dicts(existing):
old_tokens = set(entry.get("text", "").lower().split())
old_text = entry.get("text", "")
old_tokens = _content_tokens(old_text)
if not old_tokens:
continue
intersection = new_tokens & old_tokens
if not intersection:
continue
union = new_tokens | old_tokens
if len(intersection) / len(union) >= threshold:
return True
# Containment: one fact is a strict refinement of the other. Guard on
# the shorter side so two-token facts ("Likes Python") are not merged
# into longer sentences that merely start the same way.
if len(intersection) >= 3 and len(intersection) / min(len(new_tokens), len(old_tokens)) >= 0.85:
return True
# Name slot: both state the same kind of name and the values match
# (short vs full name, or the same name behind different phrasing).
old_slot = _name_slot(old_text)
if new_slot and old_slot and new_slot[0] == old_slot[0]:
if _name_vals_close(new_slot[1], old_slot[1]):
return True
return False
def _name_enrichment_target(new_text: str, existing: list):
"""Return an existing entry whose name value is a strict subset of the
new fact's, so the fuller name replaces it in place; else None."""
ns = _name_slot(new_text)
if not ns:
return None
slot, new_val = ns
best = None
for entry in _memory_dicts(existing):
old_slot = _name_slot(entry.get("text", ""))
if not old_slot or old_slot[0] != slot:
continue
old_val = old_slot[1]
if old_val < new_val and _name_vals_close(old_val, new_val) and len(new_val) <= 4 and all(t.isalpha() for t in new_val):
if best is None or len(entry.get("text", "")) < len(best.get("text", "")):
best = entry
return best
def _parse_extraction_json(raw: str) -> list:
"""Parse the extraction LLM's reply into a list of facts, tolerating
reasoning-model noise.
@@ -420,8 +587,13 @@ async def extract_and_store(
if not isinstance(facts, list):
facts = []
# The extraction model sees assistant/tool text and can mistake a
# failed lookup or a one-off request for a durable personal fact.
# Only its claims with a quoted user assertion may enter memory.
facts = [fact for fact in facts if isinstance(fact, dict)
and _has_user_fact_evidence(fact, stripped_recent)]
if fallback_facts:
facts = list(facts) + fallback_facts
facts.extend(fallback_facts)
if not facts:
logger.info("Auto memory extraction ran: 0 candidates")
@@ -456,6 +628,14 @@ async def extract_and_store(
if not fact_text or len(fact_text) < 5:
continue
# A fuller owner-specific name must not rewrite a shared legacy
# entry, but that less-specific entry must not suppress it either.
legacy_refinements = {
id(entry) for entry in existing
if _owner and entry.get("owner") is None
and _name_enrichment_target(fact_text, [entry]) is entry
}
# Dedup: check vector similarity first (fast), then exact text match.
# A runtime embedding/ChromaDB failure (backend OOM, model evicted,
# remote endpoint down) must not abort the whole batch — fall through
@@ -477,12 +657,38 @@ async def extract_and_store(
# silently dropped. Mirror the owner predicate used by the
# text dedup below; cross-tenant/stale matches fall through.
_match = next((e for e in existing if e.get("id") == existing_id), None)
if _match is not None and (_match.get("owner") == _owner or _match.get("owner") is None):
if (_match is not None and id(_match) not in legacy_refinements
and (_match.get("owner") == _owner or _match.get("owner") is None)):
logger.debug(f"Memory dedup (vector): '{fact_text[:50]}' matches {existing_id}")
continue
# Text dedup fallback: exact match + fuzzy similarity
user_existing = [e for e in existing if e.get("owner") == _owner or e.get("owner") is None] if _owner else existing
user_existing = [e for e in existing
if (e.get("owner") == _owner or e.get("owner") is None)
and id(e) not in legacy_refinements] if _owner else existing
# Name enrichment: if an existing name fact is a strict prefix of
# this more complete one, upgrade it in place instead of adding a
# near-duplicate ("name is Felix" -> "name is Felix Arvid Ulf
# Kjellberg"). The vector store upserts by id, so re-adding is safe.
# Legacy unowned entries may be visible to multiple owners; never
# rewrite one with a private fact from the current owner.
_enrich = _name_enrichment_target(
fact_text, [e for e in user_existing if e.get("owner") == _owner]
)
if _enrich is not None:
_enrich["text"] = fact_text
_enrich["timestamp"] = int(time.time())
if category:
_enrich["category"] = category
if memory_vector and memory_vector.healthy:
try:
memory_vector.add(_enrich["id"], fact_text)
except Exception as e:
logger.warning(f"Memory vector update failed for {_enrich['id']}: {e}")
added += 1
continue
if memory_manager.find_duplicates(fact_text, user_existing):
continue
# Fuzzy text similarity check (catches rephrased duplicates when vector index is unavailable)
+12 -3
View File
@@ -84,10 +84,14 @@ _FM_KEY_RE = re.compile(r"^([a-z_][a-z0-9_]*):\s*(.*)$", re.IGNORECASE)
_FM_BLOCK_LIST_RE = re.compile(r"^\s*-\s*(.*)$")
def _parse_scalar(raw: str) -> Any:
def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
raw = raw.strip()
if raw == "":
return ""
# String fields keep their spelling; quoted values still use the decoder
# below so existing escapes round-trip as before.
if string_only and not (raw[0] == raw[-1] and raw[0] in ("'", '"')):
return raw
if raw.startswith("[") and raw.endswith("]"):
inner = raw[1:-1].strip()
if not inner:
@@ -126,10 +130,15 @@ def _parse_scalar(raw: str) -> Any:
def _split_top_level(s: str, sep: str) -> List[str]:
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
out, buf, depth, quote = [], [], 0, None
escaped = False
for ch in s:
if quote:
buf.append(ch)
if ch == quote:
if escaped:
escaped = False
elif ch == "\\" and quote == '"':
escaped = True
elif ch == quote:
quote = None
continue
if ch in ("'", '"'):
@@ -171,7 +180,7 @@ def parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
pending_key = key
fm[key] = []
else:
fm[key] = _parse_scalar(val)
fm[key] = _parse_scalar(val, string_only=(key == "version"))
pending_key = None
continue
m2 = _FM_BLOCK_LIST_RE.match(line)
+16
View File
@@ -500,6 +500,19 @@ class SkillsManager:
sk.path = self._skill_file(cat, nm)
return sk.to_dict()
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
"""Whether `owner` already has a skill called `name`, in any category.
Update and delete find a skill by name within its owner, so two of
one owner's skills sharing a name would make them act on whichever
file is walked first.
"""
for path in self._iter_skill_files():
sk = self._read_skill(path)
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
return True
return False
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
"""`skill_id` is the slug name. Allows updating any field plus
renames if `name` changes (file is moved on disk).
@@ -551,6 +564,9 @@ class SkillsManager:
# Rename
new_name = slugify(updates.get("name") or sk.name)
if new_name != sk.name:
if self.has_skill(new_name, owner=owner):
logger.warning(f"Skill rename target name in use: {new_name}")
return False
sk.name = new_name
# Write to potentially new path
+16 -1
View File
@@ -64,6 +64,21 @@ _SHELL_COMMAND = (
r"nvcc|docker|systemctl|journalctl|tmux|git)"
)
_BENCHMARK_COMMAND = r"(?:[a-z][a-z0-9_-]*bench(?:mark)?s?|bench(?:mark)?s?)"
# "on <word>" and "from <word>" alone describe people, companies, and topics
# ("Jensen Huang from NVIDIA", "a book on Rome") as often as hosts, so a target
# only counts as a computer next to wording that describes work on a machine.
_NAMED_COMPUTER = (
r"(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)"
r"[a-z][a-z0-9_.-]{1,31}\b"
)
NAMED_COMPUTER_TASK = (
r"\b(?:disks?|uptime|processes|logs?|files?|folders?|director(?:y|ies)|running|installed|"
r"(?:storage|memory|ram|vram|cpu|gpu)\s+(?:usage|space|utili[sz]ation|load|temp(?:erature)?))\b"
rf"[^.?!\n]{{0,80}}\bon\s+{_NAMED_COMPUTER}"
r"|\b(?:copy|fetch|get|grab|pull|download|scp|rsync|sync|read|show|list|tail|check)\b"
r"[^.?!\n]{0,60}\b(?:files?|logs?|folders?|director(?:y|ies)|configs?|checkpoints?|backups?)\b"
rf"[^.?!\n]{{0,40}}\bfrom\s+{_NAMED_COMPUTER}"
)
_CODE_ACTION = r"(?:write|create|add|edit|modify|code|program|implement|build)"
_CODE_ARTIFACT = (
r"(?:code|function|class|script|module|component|snippet|program|app|feature|file|"
@@ -164,7 +179,7 @@ _ROUTING_PATTERNS: tuple[tuple[str, str, Pattern[str]], ...] = tuple(
("workspace", "file/code inspection request", rf"{_PLEASE}(?:find|inspect|look\s+at|open|read|check)\b.{{0,120}}\b(?:file|folder|directory|repo|repository|code|source|logs?|trace|stack|diff)\b"),
("workspace", "server/process debugging request", rf"{_PLEASE}(?:check|debug|fix|restart|start|stop|kill|tail|inspect)\b.{{0,120}}\b(?:server|service|process|port|docker|container|tmux|endpoint|logs?)\b"),
("workspace", "local computer task request", r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"),
("workspace", "named computer task request", r"\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b|that\b|it\b|same\b|current\b)(?:[a-z][a-z0-9_.-]{1,31})\b"),
("workspace", "named computer task request", NAMED_COMPUTER_TASK),
("workspace", "terminal workspace request", rf"\b(?:terminal|shell|workspace|tmux|docker|container|git|branch|commit|diff|pytest|stacktrace|traceback|{_BENCHMARK_COMMAND}|eval(?:uation)?s?)\b"),
# Shell / remote-host intent.
+61
View File
@@ -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
+4 -2
View File
@@ -240,11 +240,13 @@ def _artifact_match_is_callable(instruction: str, match: re.Match[str], path: st
def _artifact_match_is_email_host(instruction: str, match: re.Match[str]) -> bool:
"""Reject the domain portion of an email address as an output path."""
"""Reject either dotted portion of an email address as an output path."""
start = match.start("path")
prefix = instruction[max(0, start - 80):start]
return bool(re.search(r"[A-Za-z0-9_.+-]+@$", prefix))
return instruction[match.end("path"):].startswith("@") or bool(
re.search(r"[A-Za-z0-9_.+-]+@$", prefix)
)
def _workspace_path_identity(value: str) -> str:
+393 -16
View File
@@ -14,12 +14,12 @@ import csv
import difflib
import html
import json
import logging
import os
import re
import shlex
import shutil
import time
import logging
import hashlib
from src.web_recovery import WebRecoveryBudget
from itertools import count
@@ -44,6 +44,7 @@ from src.model_profiles import (
is_odysseus_merged_tools_model,
tool_schema_profile,
)
from src.action_intents import NAMED_COMPUTER_TASK
from src.agent_evidence import (
EvidenceLedger,
command_has_mutation_effect,
@@ -162,7 +163,7 @@ def _resolved_tool_call_id(
logger = logging.getLogger(__name__)
_MODEL_TOOL_SURFACES = {"none", "compact", "full"}
_MODEL_TOOL_SURFACES = {"none", "compact", "full", "spec"}
_ROUTE_THINKING_MODES = {"auto", "on", "off"}
_NO_THINKING_COMPACT_DOMAINS = {
"email",
@@ -2834,7 +2835,7 @@ def _parse_qwen_explicit_admin_request(text: str) -> Optional[tuple[str, str]]:
if re.search(r"\b(?:disabled\s+tools?|tools?\s+(?:are\s+)?(?:currently\s+)?disabled)\b", q):
return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:turn|enable|re-enable|reenable)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "enable_tool", "tool": "images"})
return "manage_settings", json.dumps({"action": "list_tools"})
if re.search(r"\b(?:disable|turn\s+off)\b.{0,40}\b(?:image\s+generation|images?|generate_image)\b", q):
return "manage_settings", json.dumps({"action": "disable_tool", "tool": "images"})
if listish and re.search(r"\bapi\s+tokens?\b|\btokens?\b", q):
@@ -8057,7 +8058,7 @@ Generate an image. Line 1 = description, line 2 = model name, line 3 = WxH (e.g.
"manage_tokens": "- ```manage_tokens``` — Generate or revoke API access tokens for external integrations. Args (JSON): {\"action\": \"list|create|delete\", ...}",
"manage_documents": "- ```manage_documents``` — List, read/open, delete, or tidy documents in the editor panel. Args (JSON): {\"action\": \"list|read|delete|tidy\", ...}. `list` returns rows like `[Title](#document-<id>) — lang, size, updated 5m ago` sorted MOST-RECENT FIRST; the user clicks the anchor to open. `read` (aliases: view/open/get) takes `document_id` and returns the content. When the user asks \"open/show/read my notes\" or \"what documents do I have\", use this — do NOT shell out, do NOT curl.",
"manage_research": "- ```manage_research``` — List, read/open, or delete saved DEEP RESEARCH results from the Library. Args (JSON): {\"action\": \"list|read|delete\", \"id\": \"<id>\", \"search\": \"...\"}. `list` returns rows like `[query](#research-<id>) — N sources` MOST-RECENT FIRST; the user clicks to open. `read` (aliases: open/view/get) takes `id` and returns the report text + sources. Use when the user says \"open/read/find/delete my research\" or \"that report\". This IS how you read a finished report: when the user refers to a just-completed deep-research job (\"check it out\", \"read that report\", \"summarize the research\") WITHOUT giving an id, call `manage_research` with `action:list` to get the most-recent id, then `action:read` with that id, and answer from the returned text. Do NOT `web_fetch`/`app_api` the `/api/research/report/{id}` URL — that endpoint renders HTML for the browser, not clean text — and do NOT start a fresh `web_search`/`trigger_research` just to read an existing report. To START new research, use trigger_research instead.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool|enable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`.",
"manage_settings": "- ```manage_settings``` — View/change the REAL app settings (same ones the Settings panel writes) AND turn tools on/off. Change a setting: `{\"action\":\"set\",\"key\":\"...\",\"value\":\"...\"}` — keys accept friendly aliases, e.g. voice→tts_voice, \"search engine\"→search_provider, \"default model\"→default_model, \"teacher model\"→teacher_model, \"task/background model\"→task_model, \"image quality\"→image_quality, \"reminder channel\"→reminder_channel (browser|email|ntfy), \"agent timeout\"/\"max tool calls\"/\"token budget\". Read: `{\"action\":\"get\",\"key\":\"...\"}`; see all: `{\"action\":\"list\"}`; reset one: `{\"action\":\"reset\",\"key\":\"...\"}`. Use this when the user asks to change ANY preference instead of making them open Settings. Secrets/API keys are read-only (tell them to set those in the panel). Tool toggles: `{\"action\":\"disable_tool\",\"tool\":\"shell\"}` (aliases: shell/search/browser/documents/memory/skills/images/tasks/notes/calendar/email), list disabled: `{\"action\":\"list_tools\"}`. You cannot re-enable a disabled tool: that is done by the user in Settings > Agent Tools.",
"manage_notes": """\
```manage_notes
{"action": "add", "title": "<short todo>", "due_date": "<natural language or ISO datetime>"}
@@ -8472,7 +8473,7 @@ def _agent_route_tool_mode(
return is_api_model, is_ollama_native, ollama_openai_compat
def _configured_model_tool_surface(
def _configured_model_tool_surface_raw(
endpoint_url: str,
model: str,
owner: Optional[str] = None,
@@ -8561,6 +8562,19 @@ _ADMIN_KEYWORDS = [
"note", "notes", "todo", "todos", "reminder", "reminders",
]
def _configured_model_tool_surface(
endpoint_url: str,
model: str,
owner: Optional[str] = None,
headers: Optional[Dict] = None,
endpoint_id: Optional[str] = None,
) -> str:
"""Tool surface as the harness sees it: Ajax Spec runs the compact runtime
(same dispatch, prompts, completions); only Spec hooks read the raw mode."""
value = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers, endpoint_id=endpoint_id)
return "compact" if value == "spec" else value
def _detect_admin_intent(messages: List[Dict]) -> bool:
"""Check if the last user message suggests admin/management tool usage."""
for msg in reversed(messages):
@@ -8751,7 +8765,7 @@ _LOCAL_COMPUTER_REFERENCE_RE = re.compile(
r"\b(?:on|from|in|using|with)\s+(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
r"|\b(?:this|my|the)\s+(?:computer|machine|pc|laptop|device|system)\b"
r"|\b(?:local|host)\s+(?:computer|machine|files?|system)\b"
r"|\b(?:on|from)\s+(?!this\b|my\b|the\b|a\b|an\b)(?:[a-z][a-z0-9_.-]{1,31})\b",
rf"|{NAMED_COMPUTER_TASK}",
re.IGNORECASE,
)
_LOCAL_NETWORK_REFERENCE_RE = re.compile(
@@ -13565,6 +13579,20 @@ def _ody_qwen_terminal_tool_summary(tool_event: dict[str, Any], user_text: str =
"""Return a deterministic user-facing answer for tools we can render safely."""
tool_name = _resolved_tool_event_name(tool_event)
output = str(tool_event.get("output") or "")
if tool_name in {
"mcp__email__list_email_accounts", "mcp__email__list_emails",
"mcp__email__search_emails", "mcp__email__read_email",
} and output.lstrip().startswith("{"):
# The external email bridge wraps stdout in a JSON result. Render its
# successful read payload, never the wrapper or a failed result.
try:
email_result = json.loads(output)
except (TypeError, ValueError):
email_result = None
if isinstance(email_result, dict) and isinstance(email_result.get("stdout"), str):
if email_result.get("exit_code") not in (None, 0) or email_result.get("error"):
return ""
output = email_result["stdout"]
command = str(tool_event.get("command") or "")
action = ""
try:
@@ -15204,7 +15232,7 @@ _ADMIN_TOOLS = {
"manage_documents", "manage_settings", "create_session", "list_sessions",
"send_to_session", "search_chats", "pipeline", "ask_teacher", "list_models",
"list_cached_models", "list_downloads", "list_cookbook_servers",
"list_serve_presets", "list_served_models",
"list_serve_presets", "list_served_models", "tail_serve_output", "search_hf_models",
}
def _build_base_prompt(
@@ -16967,6 +16995,20 @@ def _detect_runaway_call(call_freq, threshold=15):
sig = next((s for s, n in call_freq.items() if n >= threshold), None)
return sig.split(":", 1)[0] if sig else None
def _tool_approval_gate_bypassed(
exact_approval: Optional[ExactToolApproval] = None,
) -> bool:
"""Return whether the post-external-context tool approval gate is bypassed.
``tool_approval_mode=off`` (admin setting, Settings → Agent Tools) bypasses
the gate on every run, the same effect as ``ODYSSEUS_TOOL_APPROVAL_GATE=0``.
A chat-session grant bypasses it only within the scope of its own task.
"""
return (
get_setting("tool_approval_mode", "ask") == "off"
or bool(exact_approval and exact_approval.allow_remaining_actions)
)
def _tool_result_signature(tool_result_records: list[dict]) -> str:
"""Return a bounded fingerprint of the observable result of a tool batch.
@@ -21019,9 +21061,179 @@ def _blocks_before_inference(turn_contract) -> bool:
)
def _spec_unavailable_reply(unavailable) -> str:
blocked = set(unavailable or ())
if blocked & (set(WEB_TOOL_NAMES) | {"private_browser", "youtube_tool"}):
return ("Web or browser access is unavailable for this chat. Enable Web search "
"in the chat controls, if available, and ask me to check again. "
"I have not opened the page or verified its contents.")
return ("The tool needed for this request is unavailable in this chat. "
"Enable the required capability, if available, and try again. "
"I have not performed the requested lookup or action.")
from src.agent_runtime.authority import MISSING_AUTHORITY, active_request_authority, with_request_authority
def _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy):
from src.turn_contract import _ACTIVE_CONTRACT as _spec_active_contract
disabled = set(disabled_tools or ())
from src.spec_router import latest_decision
_spec_decision = latest_decision() or {}
_spec_choice = _spec_decision.get("choice")
_spec_direct = (os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
and decided and _spec_choice == "answer_directly")
try:
_mgr = get_mcp_manager()
mcp_schemas = _mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else []
except Exception:
mcp_schemas = []
mcp_names = {(sc.get("function") or {}).get("name") for sc in mcp_schemas}
if _spec_choice == "ask_user":
# ask_user = a tool is needed but a detail may be missing: offer Spec's top tool too, so the model
# can act when the detail is there or ask when it is not.
_top = _spec_decision.get("top") or []
if _top:
picks = set(picks) | {_top[0][0]}
picks = {(f"mcp__email__{n}" if f"mcp__email__{n}" in mcp_names else n) for n in picks}
strict = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict"
base_offered = set(turn_contract.offered)
# The router sees the full catalog, while this request's policy may
# deny a tool (for example, a browser turn with Web access switched
# off). Preserve that conflict as an unavailable contract. Keeping the
# old empty menu instead lets the model invent a tool result in prose.
_top = _spec_decision.get("top") or []
_primary = str(_top[0][0]) if _spec_choice and str(_spec_choice).startswith("t") and _top else ""
if _primary and f"mcp__email__{_primary}" in mcp_names:
_primary = f"mcp__email__{_primary}"
# A policy denial is surfaced even if the router chose outside the
# base inventory; it never grants access to that tool.
def _denied(name):
return (name in disabled
or (tool_policy is not None and (
tool_policy.blocks(name)
or (tool_policy.disable_mcp and name.startswith("mcp__")))))
_unavailable_pick = ""
if _primary in picks and _denied(_primary):
_unavailable_pick = _primary
elif picks and all(_denied(name) for name in picks):
_unavailable_pick = sorted(picks)[0]
if _unavailable_pick:
blocked = replace(
turn_contract, required=frozenset(), offered=frozenset(),
unavailable=frozenset({_unavailable_pick}), schema_json=(),
required_read_operation=None, routing_experiment="spec-policy-denied",
)
_spec_active_contract.set(blocked)
logger.info("[spec-harness] router pick %s unavailable under request policy", _unavailable_pick)
return blocked
want = set(picks) - disabled
# Always-on basics only when the base turn offers them, so the Spec
# arm never offers a tool the base arm would not.
if not _spec_direct:
want |= {"ask_user", "update_plan"} & base_offered
if not (strict and decided):
want |= base_offered
want &= base_offered
if not want:
logger.info("[spec-harness] router picks %s outside base offered %s; base contract kept",
sorted(picks), sorted(base_offered))
return turn_contract
offer = want & base_offered & set(turn_contract.executable)
# A router may narrow the menu only when every existing obligation
# remains satisfiable. It cannot redefine the user's requested work.
required_read = getattr(turn_contract, "required_read_operation", None)
if (not set(turn_contract.required) <= offer or turn_contract.unavailable
or (required_read is not None and required_read.tool not in offer)):
return turn_contract
by_name = {}
for sc in list(FUNCTION_TOOL_SCHEMAS) + list(mcp_schemas):
nm = (sc.get("function") or {}).get("name")
if nm in offer and nm not in by_name:
by_name[nm] = sc
for sc in turn_contract.schemas():
nm = (sc.get("function") or {}).get("name")
if nm in offer:
by_name[nm] = sc
if not by_name and not _spec_direct:
logger.info("[spec-harness] router picks %s not executable this turn; contract kept", sorted(picks))
return turn_contract
# Required tools and the required read carry over unchanged: harness
# completion logic keys on them (e.g. a calendar list is a read, not an
# effect that may end with "Done."). If one lost its schema, contract
# validation fails and the original contract is retained below.
new = replace(
turn_contract, offered=frozenset(by_name),
schema_json=tuple(json.dumps(by_name[n], ensure_ascii=False, sort_keys=True) for n in sorted(by_name)),
routing_experiment="spec-router")
_spec_active_contract.set(new)
logger.info("[spec-harness] contract replaced by router: offered=%s (was %s, decided=%s)",
sorted(by_name), sorted(turn_contract.offered), decided)
return new
def _spec_router_contract(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=None):
"""Ajax Spec: return a copy of the turn contract whose offered tools are the
router's picks (plus ask_user/update_plan), or the contract unchanged.
Runs before any runtime dispatch so the compact preview runtime and the
main loop both serve the router's set; only the offered tools change.
"""
# Contexts can be inherited by a child task; a turn must never inherit a
# previous turn's decision when routing is disabled or unavailable.
from src import spec_router as _router_context
if hasattr(_router_context, "clear_decision"):
_router_context.clear_decision()
if (turn_contract is None
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
return turn_contract
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
return turn_contract
try:
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
return turn_contract
except Exception:
return turn_contract
try:
from src.spec_router import spec_route_tools as _spec_route_tools_early
picks, decided = _spec_route_tools_early(
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
except Exception as exc:
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
return turn_contract
async def _spec_router_contract_async(turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=None):
"""Asynchronous non-blocking turn contract routing."""
from src import spec_router as _router_context
if hasattr(_router_context, "clear_decision"):
_router_context.clear_decision()
if (turn_contract is None
or getattr(turn_contract, "routing_experiment", "") in {"spec-router", "spec-policy-denied"}
or (tool_policy is not None and tool_policy.block_all_tool_calls)):
return turn_contract
if os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() not in {"strict", "additive"}:
return turn_contract
try:
if _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) != "spec":
return turn_contract
except Exception:
return turn_contract
try:
from src.spec_router import spec_route_tools_async as _spec_route_tools_early_async
picks, decided = await _spec_route_tools_early_async(
_extract_last_user_message(messages), FUNCTION_TOOL_SCHEMAS, set(), messages, return_decision=True)
return _apply_spec_decision_to_contract(turn_contract, picks, decided, disabled_tools, tool_policy)
except Exception as exc:
logger.warning("[spec-harness] router failed; retaining original contract: %s", exc)
return turn_contract
@with_request_authority
@with_turn_contract
@with_teacher_takeover
@@ -21099,6 +21311,41 @@ async def stream_agent_loop(
set(tool_policy.all_disabled_names()) if tool_policy else set()
))
# Ajax Spec driver: the Spec router picks every step (src/spec_driver.py).
if (os.getenv("ODYSSEUS_SPEC_MODE", "").strip().lower() == "driver"
and not (tool_policy and tool_policy.block_all_tool_calls)
and _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"):
yield 'data: ' + json.dumps({"delta": "Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed."}) + '\n\n'
yield 'data: [DONE]\n\n'
return
turn_contract = await _spec_router_contract_async(
turn_contract, messages, disabled_tools, endpoint_url, model, owner, headers,
tool_policy=tool_policy)
if (turn_contract is not None
and getattr(turn_contract, "routing_experiment", "") == "spec-policy-denied"):
# A selected but disallowed tool is a policy outcome, not an invitation
# to generate an ungrounded answer with an empty tool menu.
reply = _spec_unavailable_reply(turn_contract.unavailable)
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
yield 'data: ' + json.dumps({"delta": reply}) + '\n\n'
yield 'data: [DONE]\n\n'
return
try:
_spec_turn = _configured_model_tool_surface_raw(endpoint_url, model, owner, headers=headers) == "spec"
except Exception:
_spec_turn = False
# Without a turn contract (scheduled, skill and monitor runs) no Spec
# routing applies; the turn runs on the compact surface as usual.
if _spec_turn and _blocks_before_inference(turn_contract):
unavailable = ", ".join(sorted(turn_contract.unavailable))
yield 'data: ' + json.dumps({"type": "turn_contract", **turn_contract.audit()}) + '\n\n'
yield 'data: ' + json.dumps({"delta": "I can’t perform this request with the currently permitted tools "
f"(unavailable: {unavailable}). I haven’t substituted another tool."}) + '\n\n'
yield 'data: [DONE]\n\n'
return
if is_compact_preview_contract(turn_contract):
from src.clean_agent_preview import stream_preview
async for chunk in stream_preview(
@@ -21205,9 +21452,7 @@ async def stream_agent_loop(
)
or messages_contain_external_untrusted_context(messages)
),
approval_gate_bypassed=bool(
exact_approval and exact_approval.allow_remaining_actions
),
approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval),
delegated_credential=bool(delegated_credential),
)
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
@@ -23961,11 +24206,14 @@ async def stream_agent_loop(
budget_is_explicit,
hard_max=hard_max,
)
if candidate_context <= 8192:
if 0 < candidate_context <= 8192:
# Small local servers often tokenize chat wrappers and tool
# results much more generously than our rough estimator. Keep
# substantial headroom for those wrappers and generation so a
# follow-up tool round cannot exceed the server's n_ctx.
# 0 means the window is unknown, which already gets the
# conservative default budget; clamping it as well cut every
# request to 1200 tokens and dropped open documents (#6678).
effective_budget = min(
effective_budget,
max(1200, int(candidate_context * 0.40)),
@@ -24126,6 +24374,15 @@ async def stream_agent_loop(
headers=candidate_headers,
endpoint_id=(route_descriptor or {}).get("endpoint_id"),
)
# "spec" = compact tools plus a small-model router; the harness-facing
# surface already reads "compact", so detect spec from the raw mode.
try:
_spec_surface = getattr(turn_contract, "routing_experiment", "") == "spec-router" and _configured_model_tool_surface_raw(
candidate_url, candidate_model, owner, headers=candidate_headers,
endpoint_id=(route_descriptor or {}).get("endpoint_id")) == "spec"
except Exception:
_spec_surface = False
_spec_offered = None
textual_tools = (
force_textual_tool_transport
or force_textual_tools
@@ -24159,6 +24416,57 @@ async def stream_agent_loop(
_last_user,
_intent_domains,
)
if _spec_surface:
from src.spec_router import spec_route_tools as _spec_route_tools
if turn_contract is not None and getattr(turn_contract, "routing_experiment", "") == "spec-router":
_spec_added, _spec_decided = set(turn_contract.offered) - {"ask_user", "update_plan"}, True
else:
_spec_added, _spec_decided = _spec_route_tools(
_last_user,
FUNCTION_TOOL_SCHEMAS,
disabled_tools,
compacted_source,
return_decision=True,
)
_spec_added = _spec_added - set(disabled_tools)
# The router catalog uses the legacy native email names; the
# live tools are the MCP ones, and the legacy alias schemas are
# dropped when the MCP server is connected. Map picks over.
try:
from src.tool_utils import get_mcp_manager as _spec_mcp_mgr
_mgr = _spec_mcp_mgr()
_mcp_names = {
(schema.get("function") or {}).get("name")
for schema in (_mgr.get_all_openai_schemas(_load_mcp_disabled_map()) if _mgr else [])
}
except Exception:
_mcp_names = set()
_spec_added = {
(f"mcp__email__{name}" if f"mcp__email__{name}" in _mcp_names else name)
for name in _spec_added
}
_spec_mode = os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower()
_compact_before = set(route_tools)
if _spec_mode == "strict" and _spec_decided:
# The router decides which tools are injected: its pick(s),
# tools already used in this chat, and the always-on basics.
_spec_strict = (_spec_added | {"ask_user", "update_plan"}) & set(turn_contract.offered) - set(disabled_tools)
if _spec_strict:
route_tools = _spec_strict
_spec_offered = frozenset(route_tools)
else:
logger.info("[spec-harness] strict set empty after disabled-tool filter; keeping compact set")
route_tools = set(route_tools) | _spec_added
else:
route_tools = set(route_tools) | _spec_added
logger.info("[spec-debug] disabled_tools (%d): %s | policy block_all=%s disable_mcp=%s",
len(set(disabled_tools)), sorted(set(disabled_tools))[:80],
getattr(tool_policy, "block_all_tool_calls", None), getattr(tool_policy, "disable_mcp", None))
logger.info(
"[spec-harness] mode=%s decided=%s compact=%s final=%s",
_spec_mode, _spec_decided, sorted(_compact_before), sorted(route_tools),
)
# The compact router only receives user text, while native task
# inputs may be declared out-of-band by the runner. Reapply that
# concrete input contract after compaction so an implicit video
@@ -24199,7 +24507,7 @@ async def stream_agent_loop(
_irrelevant_local_media_web_tools.add("private_browser")
route_tools.difference_update(_irrelevant_local_media_web_tools)
if turn_contract is not None and tool_surface != "full":
route_tools = set(turn_contract.offered)
route_tools = set(_spec_offered) if _spec_offered else set(turn_contract.offered)
if prompt_compact:
route_tools.update(
_blocked_network_recovery_tools(tool_events)
@@ -24299,7 +24607,7 @@ async def stream_agent_loop(
disabled_tools,
needs_admin=_needs_admin,
relevant_tools=prompt_route_tools,
preserve_conversation=turn_contract is not None,
preserve_conversation=(turn_contract is not None) or _spec_surface,
mcp_disabled_map=_mcp_disabled_map,
compact=prompt_compact,
owner=owner,
@@ -24511,6 +24819,13 @@ async def stream_agent_loop(
_prepend_agent_directive(route_messages, build_active_plan_note(approved_plan))
if guide_only:
_prepend_agent_directive(route_messages, GUIDE_ONLY_DIRECTIVE)
if _spec_offered and os.getenv("ODYSSEUS_SPEC_TRIM_PROMPT", "1") != "0":
from src.spec_router import trim_system_prompt as _spec_trim
route_messages = _spec_trim(route_messages, _spec_offered)
from src.spec_router import drop_skills_index as _spec_drop_skills
route_messages = _spec_drop_skills(route_messages, _spec_offered)
return {
"messages": route_messages,
"mcp_schemas": route_mcp_schemas,
@@ -24519,6 +24834,8 @@ async def stream_agent_loop(
"is_ollama_native": is_native_ollama,
"ollama_openai_compat": is_ollama_compat,
"tool_surface": tool_surface,
"spec_offered": _spec_offered,
"spec_surface": _spec_surface,
"ody_qwen_finetune_model": is_ody,
"qwen38_tool_router": _is_qwen38_tool_router(candidate_model) and not _full_inventory_mode,
"ody_doc_finetune_mode": doc_mode,
@@ -25076,6 +25393,15 @@ async def stream_agent_loop(
if (schema.get("function", {}).get("name") or schema.get("name"))
not in disabled_tools
]
_spec_names = route_state.get("spec_offered")
if _spec_names:
# Ajax Spec: the router, not the turn contract, decides which
# tools are injected for this turn.
contract_schemas = [
schema for schema in contract_schemas
if (schema.get("function", {}).get("name") or schema.get("name"))
in _spec_names
]
return _filter_route_tool_schemas(_apply_tool_surface_to_schemas(contract_schemas, tool_surface))
if route_state["is_api_model"]:
if tool_surface == "full":
@@ -25137,6 +25463,9 @@ async def stream_agent_loop(
and schema.get("name") not in disabled_tools
]
if _pure_web_turn and tool_surface != "full":
strict_spec = (route_state.get("spec_surface") and
os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict")
if not strict_spec:
allowed = _web_only_route_tools(_last_user, disabled_tools)
schemas = [
schema for schema in schemas
@@ -25997,6 +26326,8 @@ async def stream_agent_loop(
"is_ollama_native": _is_ollama_native,
"ollama_openai_compat": _ollama_openai_compat,
"tool_surface": _route_state.get("tool_surface", ""),
"spec_offered": _route_state.get("spec_offered"),
"spec_surface": _route_state.get("spec_surface"),
"ody_qwen_finetune_model": _ody_qwen_finetune_model,
"qwen38_tool_router": _qwen38_tool_router,
"ody_doc_finetune_mode": _ody_doc_finetune_mode,
@@ -26012,10 +26343,19 @@ async def stream_agent_loop(
_active_route_state["request_messages"] = _initial_route_request_messages
all_tool_schemas = _tool_schemas_for_route(_active_route_state)
if turn_contract is not None:
_relevant_tools = set(turn_contract.offered)
_relevant_tools = (
set(_route_state.get("spec_offered"))
if _route_state.get("spec_offered")
else set(turn_contract.offered)
)
agent_stream_timeout = int(get_setting("agent_stream_timeout_seconds", 300) or 300)
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
if round_num == 1:
try:
logger.info("[spec-debug] prompt composition (chars): %s", [(m.get("role"), len(str(m.get("content") or ""))) for m in messages])
except Exception:
pass
logger.info(f"[agent-debug] round={round_num} model={model} _is_api_model={_is_api_model} tools_sent={len(_tool_names_sent)} tool_names={_tool_names_sent} relevant_tools={sorted(_relevant_tools)[:50] if _relevant_tools else 'ALL'}")
_routing_intentional_exclusions = set(disabled_tools)
if (
@@ -27509,7 +27849,16 @@ async def stream_agent_loop(
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
_streamed_tool_markup += _delta_text
if not _streamed_tool_markup_complete(_streamed_tool_markup):
if _streamed_tool_markup_starts(_streamed_tool_markup):
continue
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
# a tag prefix, but the buffer can no longer become a
# tool tag. Release it as prose instead of holding the
# rest of the answer forever.
_delta_text = _streamed_tool_markup
_streamed_tool_markup = ""
data["delta"] = _delta_text
else:
_visible_markup_tail = strip_tool_blocks(
_streamed_tool_markup,
skip_fenced=True,
@@ -27605,7 +27954,11 @@ async def stream_agent_loop(
),
active_document=active_document,
last_user=_last_user,
offered_tool_names=(set(turn_contract.offered) if turn_contract is not None else set(_tool_names_sent)),
offered_tool_names=(
set(turn_contract.offered)
if turn_contract is not None and not _active_route_state.get("spec_offered")
else set(_tool_names_sent)
),
recover_unoffered_tool_names=_recover_unoffered_local_tools,
passthrough_tool_names={
schema["function"]["name"]
@@ -32169,6 +32522,29 @@ async def stream_agent_loop(
budget_hit = False
local_network_budget_hit = False
local_inspection_budget_hit = False
if _active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and tool_blocks and not used_native and _is_api_model:
# Ajax Spec with a native-API model: a round without a native call
# carries only harness-synthesized recovery blocks. Drop them all.
logger.info("[spec-harness] dropped %d synthesized tool block(s) (no native call): %s",
len(tool_blocks), [b.tool_type for b in tool_blocks])
tool_blocks = []; converted_calls = []
if (
_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and used_native and tool_blocks
and len(converted_calls) == len(tool_blocks)
):
# Ajax Spec: only the model's own native calls run; harness-
# synthesized recovery blocks (no native call) are dropped. When
# converted_calls was reset (singleton collapse) keep every block.
_spec_keep = [
i for i in range(len(tool_blocks))
if i < len(converted_calls) and converted_calls[i] is not None
]
if len(_spec_keep) != len(tool_blocks):
logger.info("[spec-harness] dropped %d synthesized tool block(s): %s",
len(tool_blocks) - len(_spec_keep),
[tool_blocks[i].tool_type for i in range(len(tool_blocks)) if i not in _spec_keep])
tool_blocks = [tool_blocks[i] for i in _spec_keep]
converted_calls = [converted_calls[i] for i in _spec_keep]
for i, block in enumerate(tool_blocks):
native_call = converted_calls[i] if i < len(converted_calls) else None
tool_call_id = _resolved_tool_call_id(
@@ -33463,7 +33839,8 @@ async def stream_agent_loop(
),
}
if (
(_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
not (_active_route_state.get("spec_offered") and os.getenv("ODYSSEUS_SPEC_MODE", "strict").strip().lower() == "strict" and block.tool_type == "private_browser")
and (_qwen38_tool_router or _full_inventory_mode) and (_pure_web_turn or _contextual_public_web_followup)
and not _artifact_creation_requested
and (block.tool_type == "web_search" or _web_execution_budget.searches)
and not _web_execution_budget.admit(
+108 -2
View File
@@ -7,6 +7,7 @@ from dataclasses import dataclass, replace
from functools import wraps
from inspect import signature
import json
import os
from pathlib import Path
import re
from uuid import uuid4
@@ -140,9 +141,18 @@ class RequestAuthority:
raise ValueError("Malformed request authority")
if self.resource_roots is None:
roots = ()
if self.workspace:
# With no workspace, bash/python run in the agent workspace
# (agent_cwd), so the file tools get that same root (#6679).
# Imported here: authority -> execution is a new edge, and
# tool_execution imports this module at load time.
from src import tool_execution
try:
roots = (FilesystemRoot.seal(self.workspace, owner=self.owner),)
root = self.workspace
if not root:
# Vetted like agent_cwd(): a symlinked agent workspace
# raises and so grants no root.
root = tool_execution.default_agent_workspace()
roots = (FilesystemRoot.seal(root, owner=self.owner),)
except (OSError, ValueError, RuntimeError):
pass # An unresolved workspace grants no filesystem root.
object.__setattr__(self, "resource_roots", roots)
@@ -361,6 +371,101 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
def _trusted_local_mcp_request_tools(request_text: str, intent: SemanticIntent) -> set[str]:
"""Seal local MCP operations from operator declarations and trusted request intent.
Discovery validates an operator-declared tool but cannot grant it. For the
notes family, only an explicit request for local notes admits matching
read operations and explicitly requested non-destructive write verbs. Other
MCP domains require the user to name the qualified operation explicitly.
"""
from src.tool_capabilities import (
LOCAL_MCP_READ_TOOLS_ENV, LOCAL_MCP_WRITE_TOOLS_ENV,
_local_mcp_tools, _is_declared_local_mcp_read_tool,
_is_declared_local_mcp_write_tool, capabilities_for_tool, ToolEffect,
)
from src.tool_utils import get_mcp_manager
manager = get_mcp_manager()
if manager is None:
return set()
request = request_text.casefold()
local_notes = (
intent.selected_tools is None
and intent.required_read is None
and "notes" in intent.capabilities
and re.search(r"\b(?:local|on my computer|on this machine)\b", request)
)
write_verbs = {
"create": r"\b(?:create|add|make)\b",
"add": r"\b(?:add|create)\b",
"write": r"\bwrite\b",
"save": r"\bsave\b",
"update": r"\b(?:update|edit|modify|change)\b",
"edit": r"\b(?:edit|update|modify|change)\b",
"append": r"\bappend\b",
"modify": r"\b(?:modify|edit|update|change)\b",
}
negative_write = re.search(
r"\b(?:do not|don't|never|without|no)\s+(?:\w+\s+){0,2}"
r"(?:create|add|make|write|save|update|edit|append|modify)\b",
request,
)
declared = _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV) | _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
admitted = set()
for name in sorted(declared):
parts = name.split("__", 2)
if len(parts) != 3 or parts[0] != "mcp":
continue
_, server, bare = parts
if manager.is_builtin(server):
continue
conn = manager.get_server_status(server)
if conn.get("status") != "connected" or conn.get("transport") != "stdio":
continue
resource = manager.resource_identity(name)
if resource is None or resource.namespace != "mcp" or resource.endpoint_id != f"stdio:{server}":
continue
rows = manager._tools.get(server, [])
row = next((item for item in rows if item.get("name") == bare), None)
if row is None:
continue
read_ok = _is_declared_local_mcp_read_tool(name)
write_ok = _is_declared_local_mcp_write_tool(name)
if not (read_ok or write_ok):
continue
caps = capabilities_for_tool(name)
if not caps.known or caps.effects != frozenset({
ToolEffect.READ_PRIVATE if read_ok else ToolEffect.WRITE_PRIVATE
}):
continue
ann = row.get("annotations") or {}
destructive = ann.get("destructiveHint") if isinstance(ann, dict) else getattr(ann, "destructiveHint", None)
if destructive is True:
continue
if write_ok and re.match(r"^(?:delete|remove|erase|destroy|drop|purge)(?:_|$)", bare, re.I):
continue
# Exact qualified names in the trusted request permit any configured
# and validated local operation. Model/tool content is not an input.
explicitly_named = bool(re.search(
r"(?<![A-Za-z0-9_])" + re.escape(name.casefold()) + r"(?![A-Za-z0-9_])",
request,
))
if not explicitly_named:
if not local_notes or not re.search(r"(?:^|_)notes?(?:_|$)", bare, re.I):
continue
if write_ok:
action = bare.split("_", 1)[0].casefold()
if action not in write_verbs or not re.search(write_verbs[action], request):
continue
if negative_write:
continue
admitted.add(name)
return admitted
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
history=(), policy=None, active_document=False,
image_attachment=False, capabilities=None, client_runtime_context=None):
@@ -383,6 +488,7 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
tools = {canonical_tool(operation.tool)}
else:
operation = None
tools.update(_trusted_local_mcp_request_tools(request_text, intent))
grants = []
for name in sorted(tools | {"ask_user", "update_plan"}):
name = canonical_tool(name)
+12
View File
@@ -230,6 +230,18 @@ def classify(capture: DispatchCapture) -> dict[str, Any] | None:
if capture.backend is not None:
from src.agent_runtime.resources import ExternalResource
if isinstance(capture.backend.resource, ExternalResource):
# A read through an external backend is still a read. The tool
# capability registry is sealed by the dispatcher, and unknown or
# mixed-effect calls continue to receive an external effect claim.
if all(bound is None for bound in (capture.browser, capture.filesystem,
capture.process, capture.owned)):
from src.tool_capabilities import ToolEffect, capabilities_for_action
capabilities = capabilities_for_action(capture.backend.transport_tool,
capture.backend.exact_input)
reads = frozenset({ToolEffect.READ_PUBLIC, ToolEffect.READ_WORKSPACE,
ToolEffect.READ_PRIVATE, ToolEffect.BROKERED_NETWORK_READ})
if capabilities.known and capabilities.effects and capabilities.effects <= reads:
return None
external = True
impact = (*impact, resource_ref(capture.backend.resource, "backend"))
return {"impact_scope": impact, "dependencies": dependencies, "obligations": obligations, "external": external}
+6 -5
View File
@@ -47,6 +47,7 @@ EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
_RUN_ID = re.compile(r"[a-f0-9]{32}")
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
_VERSION = 1
_O_BINARY = getattr(os, "O_BINARY", 0) # Windows: raw os.open() is text-mode without it
def _fsync_directory(directory: str | os.PathLike) -> None:
@@ -243,7 +244,7 @@ class EffectLog:
"""Hold the file exclusively with every durable record merged."""
assert self.path is not None
_ensure_directory(self.path.parent)
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
descriptor = os.open(self.path, flags, 0o600)
try:
if fcntl is not None:
@@ -358,7 +359,7 @@ class EffectLog:
with self._lock:
try:
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_CLOEXEC", 0))
| getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
except FileNotFoundError:
return
except OSError:
@@ -394,7 +395,7 @@ class EffectLog:
log = cls(run_id, directory=directory)
assert log.path is not None
try:
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0))
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
except FileNotFoundError:
return log
except OSError as error:
@@ -460,7 +461,7 @@ class EffectLog:
temporary = target.with_suffix(".tmp")
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
_ensure_directory(target.parent)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
descriptor = os.open(temporary, flags, 0o600)
try:
view = memoryview(data)
@@ -478,7 +479,7 @@ class EffectLog:
return None
base = Path(directory) if directory is not None else effects_dir()
try:
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | _O_BINARY)
with os.fdopen(descriptor, "rb") as stream:
if os.fstat(stream.fileno()).st_nlink != 1:
return None
+5
View File
@@ -29,6 +29,7 @@ from .interaction_tools import AskUserTool, UpdatePlanTool
from .model_interaction_tools import ChatWithModelTool, AskTeacherTool, ListModelsTool
from .bg_job_tools import ManageBgJobsTool
from .session_tools import CreateSessionTool, ListSessionsTool, SendToSessionTool, ManageSessionTool
from .editor_tools import PlanEditTool, RunEditorCommandTool, GetEditorScreenshotTool, AiEditImageTool
from .admin_tools import (
ADMIN_TOOL_HANDLERS,
do_manage_endpoints, do_manage_mcp, do_manage_webhooks,
@@ -72,6 +73,10 @@ TOOL_HANDLERS = {
"list_sessions": ListSessionsTool().execute,
"send_to_session": SendToSessionTool().execute,
"manage_session": ManageSessionTool().execute,
"plan_edit": PlanEditTool().execute,
"run_editor_command": RunEditorCommandTool().execute,
"get_editor_screenshot": GetEditorScreenshotTool().execute,
"ai_edit_image": AiEditImageTool().execute,
}
# Config/integration admin tools (manage_endpoints/mcp/webhooks/tokens/settings).
TOOL_HANDLERS.update(ADMIN_TOOL_HANDLERS)
+32 -10
View File
@@ -585,7 +585,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
return _ALIASES_SET.get(k2, (k or "").strip())
def _is_managed_key(key):
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
# Tool policy is not a general setting, even if a future default
# is added: resetting it must never re-enable disabled tools.
return key != "disabled_tools" and key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
_ENUMS = {
"image_quality": ["low", "medium", "high"],
@@ -713,13 +715,35 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
save_settings(s)
return {"response": f"Reset {key} to default ({DEFAULT_SETTINGS[key]}).", "exit_code": 0}
elif action in ("disable_tool", "enable_tool", "list_tools"):
elif action == "enable_tool":
# The global disabled_tools denylist is the one hard permission
# boundary the Settings -> Agent Tools panel offers admins. It
# must only ever be *narrowed* from inside an agent turn: a
# model that can call enable_tool can undo the boundary itself,
# and any web page, email, or document the agent reads can ask
# it to (prompt injection re-enabling bash/email/etc.). Widening
# the tool set stays a human action in the Settings panel.
tool_name = (args.get("tool") or args.get("name") or "").strip().lower()
return {
"error": (
f"Re-enabling tools is not available from chat"
f"{f' (requested: {tool_name})' if tool_name else ''}. "
"Disabled tools are an admin policy: ask the user to turn the tool "
"back on in Settings > Agent Tools. You can still disable tools "
"(disable_tool) or list the current state (list_tools)."
),
"exit_code": 1,
}
elif action in ("disable_tool", "list_tools"):
# Tool-toggle actions. These edit settings.json:disabled_tools
# (the global list read on every chat request) rather than
# prefs.json. Friendly aliases accepted: "shell" -> "bash",
# "search" -> "web_search", "browser" -> browser tools,
# "documents" -> the document tool set, "memory" ->
# manage_memory, etc.
# manage_memory, etc. Only disable_tool mutates: see the
# enable_tool branch above for why the agent can never widen
# the denylist.
from src.settings import get_setting, save_settings, load_settings
_ALIASES = {
"shell": ["bash"],
@@ -753,7 +777,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
"response": (
f"Currently disabled: {', '.join(current) if current else '(none)'}.\n"
"Common toggles: shell (bash), search (web_search), browser, documents, "
"memory, skills, images, tasks, notes, calendar, email."
"memory, skills, images, tasks, notes, calendar, email. "
"To re-enable a tool, open Settings > Agent Tools."
),
"disabled": list(current),
"exit_code": 0,
@@ -767,22 +792,19 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
settings = load_settings()
current = list(settings.get("disabled_tools") or [])
before = set(current)
if action == "disable_tool":
for t in targets:
if t not in current:
current.append(t)
else: # enable_tool
current = [t for t in current if t not in targets]
after = set(current)
settings["disabled_tools"] = current
save_settings(settings)
verb = "Disabled" if action == "disable_tool" else "Enabled"
changed = sorted(after.symmetric_difference(before))
return {
"response": (
f"{verb} {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}."
f"Disabled {tool_name} ({', '.join(targets)}). "
f"Now disabled: {', '.join(current) if current else '(none)'}. "
"Re-enabling is done by the user in Settings > Agent Tools."
),
"changed": changed,
"disabled": list(current),
+12 -3
View File
@@ -4,7 +4,7 @@ import html
import difflib
import logging
import re
from src.constants import MAX_READ_CHARS
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
from src.tool_approvals import document_content_digest
from src.tool_utils import _parse_tool_args, get_upload_handler
from src.upload_handler import reserve_upload_references
@@ -1175,14 +1175,23 @@ class ManageDocumentTool:
except (TypeError, ValueError):
offset = 0
offset = min(offset, len(body))
anchor = f"[{doc.title}](#document-{doc.id})"
header = f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n"
# Reserve the closing fence and the longest possible paging
# notice before selecting the page. The common output cap must
# never hide content that next_offset claims was delivered.
notice = f"\n... (truncated, {len(body)} chars total; next_offset={len(body)})"
page_budget = MAX_OUTPUT_CHARS - len(header) - len(notice) - len("\n```")
if page_budget < 1:
return {"error": "Document title or language is too long to display a read page", "exit_code": 1}
preview_limit = min(preview_limit, page_budget)
end = min(offset + preview_limit, len(body))
truncated = end < len(body)
preview = body[offset:end]
if truncated:
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
anchor = f"[{doc.title}](#document-{doc.id})"
return {
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
"response": f"{header}{preview}\n```",
"document": {
"id": doc.id,
"title": doc.title,
+703
View File
@@ -0,0 +1,703 @@
"""In-process agent tools for the scoped ``photo_editor`` capability family.
These handlers run as the logged-in owner and drive the image editor over the
owner-scoped in-process bridge (``/api/editor/bridge/*``) plus the app's
existing image transforms (``/api/image/*``). They are only offered to the
model when the ``photo_editor`` family is active (the Quick Edit popup or an
explicit photo-edit chat turn), never in the general tool list.
Loopback calls carry the internal token and impersonate ``owner`` (middleware
app.py), so the editor bridge resolves the same ``owner:session`` scope the
browser subscriber uses.
"""
from __future__ import annotations
import base64
import hashlib
import io
import json
import logging
import re
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
import httpx
from src.tools._common import _INTERNAL_BASE, _internal_headers
logger = logging.getLogger(__name__)
def _provider_message() -> str:
try:
from src.ai_interaction import IMAGE_PROVIDER_REQUIRED_MESSAGE
return IMAGE_PROVIDER_REQUIRED_MESSAGE
except Exception:
return (
"Removing/adding objects needs an image generation provider; enable one "
"in Settings > Models."
)
def _provider_available(owner: Optional[str]) -> bool:
try:
from src.ai_interaction import image_editing_provider_available
return bool(image_editing_provider_available(owner))
except Exception:
return True
# Local diffusion/image servers can take 90-110s per image (8 steps) and
# longer on a cold start; the previous 120s default timed out mid-generation.
_EDITOR_TIMEOUT = 300.0
# A mask covering less than this fraction of the image cannot drive a useful
# regional inpaint (tiny figures, empty SAM output): fall back to grounding or
# whole-image instruction mode instead.
_MIN_MASK_FRACTION = 0.002
_AI_OPS = {
"remove-bg": "/api/image/remove-bg",
"upscale": "/api/image/upscale-local",
"denoise": "/api/image/denoise",
"enhance-face": "/api/image/enhance-face",
"inpaint": "/api/image/inpaint",
"generative": "/api/image/inpaint",
}
_PLACE_OP = "layers.placeImage"
def _parse(content: Any) -> Dict[str, Any]:
raw = content if isinstance(content, str) else json.dumps(content or {})
try:
parsed = json.loads(raw) if raw.strip() else {}
except (ValueError, TypeError):
parsed = {}
return parsed if isinstance(parsed, dict) else {}
def _session(args: Dict[str, Any], ctx: Dict[str, Any]) -> str:
# Explicit editor session bound in context is authoritative;
# otherwise fallback to caller/tool args, then default.
# Note: ctx.get("session_id") is the chat/turn UUID, NOT the editor canvas session.
return str(ctx.get("editor_session") or args.get("session") or args.get("editor_session") or "default")
def _incognito(ctx: Dict[str, Any]) -> bool:
runtime = ctx.get("client_runtime_context")
state = runtime.get("web_ui_state") if isinstance(runtime, dict) else None
return ctx.get("incognito") is True or (isinstance(state, dict) and state.get("incognito") is True)
async def _post(path: str, payload: Dict[str, Any], owner: Optional[str], timeout: float = _EDITOR_TIMEOUT) -> Dict[str, Any]:
headers = {**_internal_headers(owner), "Content-Type": "application/json"}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(f"{_INTERNAL_BASE}{path}", json=payload, headers=headers)
resp.raise_for_status()
try:
return resp.json()
except ValueError:
return {"raw": resp.text}
def _as_data_url(value: Any) -> Optional[str]:
if not isinstance(value, str) or not value:
return None
if value.startswith("data:"):
return value
if value.startswith("/"):
return None
return f"data:image/png;base64,{value}"
def _data_url_bytes(data_url: str) -> bytes:
_, _, b64 = data_url.partition(",")
return base64.b64decode(b64)
def _image_reference_path(value: str) -> bool:
# Never use the internal tool credential to fetch an arbitrary app route.
generated = re.fullmatch(r"/api/generated-image/[a-f0-9]{8,64}\.(?:png|jpg|jpeg|webp|gif)", value)
upload = re.fullmatch(r"/api/upload/[a-fA-F0-9]{32}(?:\.[A-Za-z0-9]+)?", value)
return bool(generated or upload)
def _persist_png(image_bytes: bytes, owner: Optional[str], session_id: Optional[str], prompt: str, model: str) -> Dict[str, Any]:
"""Save an edited/generated PNG and return {image_id, image_url, image_size}."""
from core.database import GalleryImage, SessionLocal
from src.constants import GENERATED_IMAGES_DIR
db = SessionLocal()
image_path = None
try:
root = Path(GENERATED_IMAGES_DIR).resolve()
root.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
image_path = root / filename
image_path.write_bytes(image_bytes)
new_id = str(uuid.uuid4())
width = height = 0
try:
from PIL import Image
with Image.open(io.BytesIO(image_bytes)) as img:
width, height = img.size
except Exception:
pass
size = f"{width}x{height}" if width and height else ""
# The editor bridge uses an owner-scoped session id (e.g. "pewds:3")
# that is not a chat-sessions row. GalleryImage.session_id is a FK to
# sessions.id, so drop an unknown id instead of raising IntegrityError.
if session_id:
from core.database import Session as _ChatSession
try:
known = db.query(_ChatSession.id).filter(_ChatSession.id == session_id).first()
except Exception:
known = None
if known is None:
session_id = None
derived = GalleryImage(
id=new_id,
filename=filename,
prompt=prompt or None,
caption=None,
model=model,
size=size or None,
quality=None,
tags=None,
ai_tags=None,
session_id=session_id,
album_id=None,
owner=owner,
file_hash=hashlib.sha256(image_bytes).hexdigest(),
file_size=len(image_bytes),
width=width or None,
height=height or None,
)
db.add(derived)
db.commit()
return {"image_id": new_id, "image_url": f"/api/generated-image/{filename}", "image_size": size or None}
except Exception:
db.rollback()
if image_path is not None:
try:
image_path.unlink(missing_ok=True)
except OSError:
logger.warning("Failed to remove uncommitted editor image", exc_info=True)
raise
finally:
db.close()
async def _place_layer(data_url: str, name: str, owner: Optional[str], session: str, *, masked: bool = False, blend: str = "normal", opacity: float = 100.0, mode: Optional[str] = None, native_size: bool = False) -> bool:
params: Dict[str, Any] = {
"dataUrl": data_url,
"name": name,
"masked": bool(masked),
"blend": blend or "normal",
"opacity": opacity,
}
if native_size:
params["nativeSize"] = True
# Forward the edit mode so the client can build a difference mask for
# instruction-mode results: the whole image was regenerated under an
# instruction prompt, so untouched pixels must stay pixel-identical (the
# client keeps only the changed region) instead of the new layer overwriting
# the entire canvas. Omit the keys entirely when no mode is given so the
# client keeps its current behavior (e.g. generate_image must not diff-mask).
if mode:
params["mode"] = mode
params["differenceMask"] = mode == "instruction"
if mode == "instruction":
# Exclude a thin edge band from the client difference mask: full-frame
# regenerations can shift pixels along the border.
params["edgeExcludePx"] = 3
payload = {
"session": session,
"op": _PLACE_OP,
"params": params,
"wait": True,
"timeout": 60,
}
try:
data = await _post("/api/editor/bridge/command", payload, owner, timeout=90)
return bool(data.get("ok") is True and data.get("completed", True) is not False)
except Exception as exc: # pragma: no cover - defensive
logger.warning("editor layer placement failed: %s", exc)
return False
async def _fetch_selection_mask(owner: Optional[str], session: str) -> Optional[str]:
"""Return the editor's current selection as a data-URL mask, or None.
Generative ops (inpaint/generative) need a mask; the planner/judge may not
have supplied one, so fall back to whatever is selected in the editor.
"""
payload = {
"session": session,
"op": "selection.exportMask",
"params": {},
"wait": True,
"timeout": 30,
}
try:
data = await _post("/api/editor/bridge/command", payload, owner, timeout=45)
except Exception as exc: # pragma: no cover - defensive
logger.warning("editor selection mask fetch failed: %s", exc)
return None
result = data.get("result") if isinstance(data, dict) else None
if isinstance(result, dict):
return _as_data_url(result.get("mask"))
return None
def _mask_white_fraction(data_url: Optional[str]) -> float:
"""Fraction of a grayscale mask that is selected (white, >=128)."""
if not data_url:
return 0.0
try:
from PIL import Image as _PILImage
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))).convert("L") as im:
total = im.width * im.height
if not total:
return 0.0
hist = im.histogram()
return sum(hist[128:]) / total
except Exception:
return 0.0
def _image_is_blank(data_url: str) -> bool:
"""True when a result has an alpha channel that is essentially transparent."""
try:
from PIL import Image as _PILImage
with _PILImage.open(io.BytesIO(_data_url_bytes(data_url))) as im:
if im.mode not in ("RGBA", "LA") and "transparency" not in im.info:
return False
alpha = im.convert("RGBA").getchannel("A")
hist = alpha.histogram()
total = sum(hist)
return total > 0 and (sum(hist[1:]) / total) < 0.001
except Exception:
return False
def _rect_mask_data_url(image_data_url: str, box: List[float], *, pad_frac: float = 0.15) -> Optional[str]:
"""Build a rectangular mask data URL from a [x1,y1,x2,y2] box, dilated.
Used when the grounding detector finds an object but SAM returns an empty
mask (tiny figures): a dilated box guarantees a usable region.
"""
try:
from PIL import Image as _PILImage, ImageDraw
with _PILImage.open(io.BytesIO(_data_url_bytes(image_data_url))) as im:
w, h = im.size
x1, y1, x2, y2 = (float(v) for v in box)
bw, bh = x2 - x1, y2 - y1
x1, y1 = max(0.0, x1 - bw * pad_frac), max(0.0, y1 - bh * pad_frac)
x2, y2 = min(float(w), x2 + bw * pad_frac), min(float(h), y2 + bh * pad_frac)
mask = _PILImage.new("L", (w, h), 0)
ImageDraw.Draw(mask).rectangle([x1, y1, x2, y2], fill=255)
buf = io.BytesIO()
mask.save(buf, "PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception:
return None
async def _ground_box(owner: Optional[str], image_data_url: str, label: str) -> Optional[List[float]]:
"""Use the in-app grounding detector (OWL-ViT via /api/image/mask) for a box."""
try:
data = await _post("/api/image/mask", {"image": image_data_url, "text": label}, owner, timeout=90)
except Exception as exc: # pragma: no cover - defensive
logger.warning("editor grounding box failed: %s", exc)
return None
box = data.get("bbox") if isinstance(data, dict) else None
if isinstance(box, (list, tuple)) and len(box) == 4:
try:
return [float(v) for v in box]
except Exception:
return None
return None
def _ensure_keep_clause(text: str) -> str:
"""Append a keep-the-subject clause when the prompt lacks one."""
body = str(text or "").strip()
if not body:
return body
if re.search(r"\b(keep|preserve|leave|unchanged|identical|same)\b", body, re.I):
return body
return body.rstrip(". ") + ". Keep the main subject and everything else in the image exactly the same."
def _mask_label(args: Dict[str, Any], prompt: str) -> str:
"""Best-effort object noun for grounding a regional removal."""
label = str(args.get("label") or args.get("object") or "").strip()
if label:
return label
m = re.search(
r"\b(?:remove|delete|erase|eliminate|get\s+rid\s+of|take\s+out)\s+(?:the\s+|a\s+|an\s+)?"
r"([a-z][a-z \-]{1,30}?)(?:\s+(?:on|in|at|from|to|near|behind|beside|by)\b|[.,;!?]|$)",
prompt or "",
re.I,
)
if m:
noun = m.group(1).strip().lower()
if noun and noun not in {"background", "bg"}:
return noun
for noun in ("person", "people", "man", "woman", "car", "dog", "sign", "logo"):
if re.search(rf"\b{re.escape(noun)}\b", prompt or "", re.I):
return "person" if noun in {"person", "people", "man", "woman"} else noun
return "object"
class PlanEditTool:
"""Plan an image edit as ordered editor operations (no side effects)."""
async def execute(self, content: Any, ctx: Dict[str, Any]):
args = _parse(content)
prompt = str(args.get("prompt") or "").strip()
if not prompt:
return "plan_edit: invalid", {"error": "plan_edit needs a non-empty `prompt`.", "exit_code": 1}
state = args.get("state") if isinstance(args.get("state"), dict) else {}
try:
data = await _post(
"/api/editor/plan",
{"prompt": prompt, "state": state, "mode": "plan", "session": _session(args, ctx)},
ctx.get("owner"),
)
except Exception as exc:
return "plan_edit: error", {"error": f"plan_edit failed: {exc}", "exit_code": 1}
steps = data.get("steps") or []
reply = str(data.get("reply") or "")
return f"plan_edit: {len(steps)} step(s)", {
"output": reply or f"Planned {len(steps)} editor step(s).",
"reply": reply,
"planner": data.get("planner"),
"steps": steps,
"exit_code": 0,
}
class RunEditorCommandTool:
"""Run a single validated editor op in the browser via the owner bridge."""
async def execute(self, content: Any, ctx: Dict[str, Any]):
args = _parse(content)
op = str(args.get("op") or "").strip()
if not op:
return "run_editor_command: invalid", {"error": "run_editor_command needs `op`.", "exit_code": 1, "completed": False}
params = args.get("params") if isinstance(args.get("params"), dict) else {}
wait = bool(args.get("wait", True))
payload = {
"session": _session(args, ctx),
"op": op,
"params": params,
"wait": wait,
"timeout": float(args.get("timeout") or 60),
}
try:
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"))
except Exception as exc:
return f"run_editor_command: {op}", {"error": f"run_editor_command failed: {exc}", "exit_code": 1, "completed": False, "status": "failed"}
is_queued = bool(data.get("queued")) or data.get("status") == "queued"
if is_queued and (not wait or not data.get("ok")):
summary = json.dumps({"status": "queued", "queued": data.get("queued"), "session": data.get("session")}, ensure_ascii=False)
return f"run_editor_command: {op}", {
"output": summary,
"queued": data.get("queued"),
"status": "queued",
"pending": True,
"completed": False,
"ok": False,
"exit_code": 1,
}
ok = bool(data.get("ok") is True and data.get("completed", True) is not False)
status = data.get("status") or ("completed" if ok else "failed")
result = data.get("result")
summary = json.dumps({"ok": ok, "status": status, "error": data.get("error"), "result": result}, ensure_ascii=False)
return f"run_editor_command: {op}", {
"output": summary[:4000],
"result": result,
"screenshot": data.get("screenshot"),
"status": status,
"completed": ok,
"ok": ok,
"exit_code": 0 if ok else 1,
}
class GetEditorScreenshotTool:
"""Capture the current editor canvas as a data URL."""
async def execute(self, content: Any, ctx: Dict[str, Any]):
args = _parse(content)
payload = {
"session": _session(args, ctx),
"op": "screenshot",
"params": {},
"wait": True,
"timeout": float(args.get("timeout") or 30),
}
try:
data = await _post("/api/editor/bridge/command", payload, ctx.get("owner"), timeout=45)
except Exception as exc:
return "get_editor_screenshot: error", {"error": f"get_editor_screenshot failed: {exc}", "exit_code": 1}
result = data.get("result") if isinstance(data.get("result"), dict) else {}
shot = data.get("screenshot") or result.get("dataUrl")
if not shot:
return "get_editor_screenshot: unavailable", {
"error": "No editor screenshot was returned (is the editor open?).",
"exit_code": 1,
}
return "get_editor_screenshot", {"output": "Captured the editor canvas.", "screenshot": shot, "exit_code": 0}
class AiEditImageTool:
"""Apply an AI image transform (remove-bg/upscale/denoise/enhance/inpaint)."""
async def execute(self, content: Any, ctx: Dict[str, Any]):
args = _parse(content)
_t = {"start": time.perf_counter()}
op = str(args.get("op") or "").strip().lower()
if op not in _AI_OPS:
return "ai_edit_image: invalid", {
"error": f"ai_edit_image `op` must be one of {sorted(_AI_OPS)}.",
"exit_code": 1,
}
owner = ctx.get("owner")
if op in ("inpaint", "generative") and not _provider_available(owner):
return "ai_edit_image: provider required", {
"error": _provider_message(),
"failure_kind": "image_provider_unavailable",
"needs_provider": True,
"exit_code": 1,
}
session = _session(args, ctx)
image = _as_data_url(args.get("image"))
if image is None and isinstance(args.get("image"), str) and args["image"].startswith("/"):
# A same-origin gallery/attachment URL: fetch it (owner-scoped) as bytes.
if not _image_reference_path(args["image"]):
return "ai_edit_image: invalid image", {"error": "Invalid image reference.", "exit_code": 1}
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(f"{_INTERNAL_BASE}{args['image']}", headers=_internal_headers(owner))
resp.raise_for_status()
if not resp.headers.get("content-type", "").lower().startswith("image/"):
return "ai_edit_image: invalid image", {"error": "Image reference is not an image.", "exit_code": 1}
image = _as_data_url("data:image/png;base64," + base64.b64encode(resp.content).decode())
except Exception:
image = None
used_canvas = False
if not image:
shot = await GetEditorScreenshotTool().execute(json.dumps({"session": session}), ctx)
image = (shot[1] or {}).get("screenshot") if isinstance(shot, tuple) else None
used_canvas = bool(image)
if not image:
return "ai_edit_image: no image", {
"error": "ai_edit_image needs an `image` (data URL or same-origin URL) or an open editor.",
"exit_code": 1,
}
_t["image"] = time.perf_counter()
payload: Dict[str, Any] = {"image": image}
if args.get("_use_image_settings") is True:
payload["_use_image_settings"] = True
model_choice = str(args.get("model") or "").strip()
mask_used = False
place_mode: Optional[str] = None
if op == "upscale":
payload["scale"] = int(args.get("scale") or 2)
elif op == "denoise":
payload["strength"] = float(args.get("strength") or 0.5)
elif op in ("inpaint", "generative"):
payload["prompt"] = str(args.get("prompt") or "").strip()
if model_choice:
# inpaint_proxy reads `_model` to route to a specific model.
payload["_model"] = model_choice
mode = str(args.get("mode") or "instruction").strip().lower()
_steps = int(args.get("steps") or 0)
if _steps > 0:
payload["steps"] = _steps
mask_used = False
mask = None
if mode == "masked":
mask = _as_data_url(args.get("mask"))
if not mask:
mask = await _fetch_selection_mask(owner, session)
# An empty/tiny SAM mask on small figures is useless: drop it.
if mask and _mask_white_fraction(mask) < _MIN_MASK_FRACTION:
mask = None
if not mask:
# Fall back to the grounding detector (OWL-ViT): a dilated
# box mask around the matching object.
box = await _ground_box(owner, image, _mask_label(args, payload["prompt"]))
if box:
candidate = _rect_mask_data_url(image, box, pad_frac=0.15)
if candidate and _mask_white_fraction(candidate) >= _MIN_MASK_FRACTION:
mask = candidate
if mask:
payload["mask"] = mask
payload["mode"] = "masked"
mask_used = True
else:
# Nothing usable to mask -> whole-image instruction fallback
# rather than placing a transparent/empty layer.
mode = "instruction"
if mode != "masked":
# Instruction mode: whole-image edit with no tight mask. Send a
# full-white mask so the edit endpoint accepts the request and
# regenerates the whole frame under the instruction prompt, which
# names what must stay unchanged. This avoids the mask-compositing
# halos that loose selections caused.
payload["mode"] = "instruction"
payload.pop("mask", None)
payload["prompt"] = _ensure_keep_clause(payload["prompt"])
try:
from PIL import Image as _PILImage
_raw = _data_url_bytes(image)
_w, _h = _PILImage.open(io.BytesIO(_raw)).size
_mbuf = io.BytesIO()
_PILImage.new("L", (_w, _h), 255).save(_mbuf, "PNG")
payload["mask"] = "data:image/png;base64," + base64.b64encode(_mbuf.getvalue()).decode()
except Exception:
pass
place_mode = "masked" if mask_used else "instruction"
_t["prep"] = time.perf_counter()
try:
data = await _post(_AI_OPS[op], payload, owner)
except Exception as exc:
logger.info(
"image-op ai_edit_image op=%s stage=provider FAILED image_ms=%.1f prep_ms=%.1f total_ms=%.1f err=%s",
op, (_t["image"] - _t["start"]) * 1000.0, (_t["prep"] - _t["image"]) * 1000.0,
(time.perf_counter() - _t["start"]) * 1000.0, exc,
)
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) failed: {exc}", "exit_code": 1}
_t["provider"] = time.perf_counter()
result = data.get("image") or data.get("data")
data_url = _as_data_url(result)
if not data_url:
return f"ai_edit_image: {op}", {"error": f"ai_edit_image ({op}) returned no image.", "exit_code": 1}
if _image_is_blank(data_url):
return f"ai_edit_image: {op}", {
"error": f"ai_edit_image ({op}) produced an empty layer.",
"failure_kind": "empty_result",
"exit_code": 1,
}
info: Dict[str, Any] = {
"output": f"Applied AI edit '{op}'" + (" to the current canvas." if used_canvas else "."),
"op": op,
"model": data.get("model") or model_choice or "",
"exit_code": 0,
}
if _incognito(ctx):
info["image_url"] = data_url
else:
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), f"ai_edit_image:{op}", f"ai_edit_image:{op}"))
except Exception as exc:
logger.warning("ai_edit_image persist failed: %s", exc)
_t["persist"] = time.perf_counter()
if bool(args.get("place", True)):
placed = await _place_layer(
data_url,
f"AI {op}",
owner,
session,
masked=mask_used and data.get("full_frame") is not True,
blend=str(args.get("blend") or "normal"),
opacity=float(args.get("opacity") or 100),
mode=place_mode,
native_size=data.get("full_frame") is True,
)
info["placed"] = placed
if placed:
info["output"] += " Placed the result as a new layer."
_t["place"] = time.perf_counter()
logger.info(
"image-op ai_edit_image op=%s mode=%s canvas=%s image_ms=%.1f mask_ms=%.1f "
"model_ms=%.1f persist_ms=%.1f place_ms=%.1f total_ms=%.1f",
op, place_mode or "instruction", used_canvas,
(_t["image"] - _t["start"]) * 1000.0,
(_t["prep"] - _t["image"]) * 1000.0,
(_t["provider"] - _t["prep"]) * 1000.0,
(_t["persist"] - _t["provider"]) * 1000.0,
(_t["place"] - _t["persist"]) * 1000.0,
(_t["place"] - _t["start"]) * 1000.0,
)
return f"ai_edit_image: {op}", info
class GenerateImageLayerTool:
"""Generate an image with the configured image model and place it as a layer."""
async def execute(self, content: str, ctx: dict):
args = _parse(content)
prompt = str(args.get("prompt") or "").strip()
if not prompt:
return "generate_image: invalid", {"error": "generate_image needs a non-empty `prompt`.", "exit_code": 1}
owner = ctx.get("owner")
if not _provider_available(owner):
return "generate_image: provider required", {
"error": _provider_message(),
"failure_kind": "image_provider_unavailable",
"needs_provider": True,
"exit_code": 1,
}
session = _session(args, ctx)
payload: Dict[str, Any] = {"prompt": prompt}
for key in ("model", "size", "quality"):
if args.get(key):
payload[key] = args[key]
try:
from src.ai_interaction import do_generate_image
data = await do_generate_image(
json.dumps(payload, ensure_ascii=False),
session_id=session,
owner=owner,
incognito=True,
)
except Exception as exc:
return "generate_image: error", {"error": f"generate_image failed: {exc}", "exit_code": 1}
result = data.get("image_url") or data.get("image") or data.get("data")
data_url = _as_data_url(result)
if not data_url:
return "generate_image", {"error": "generate_image returned no image.", "exit_code": 1}
info: Dict[str, Any] = {
"output": f"Generated an image for: {prompt[:120]}",
"image_size": data.get("image_size"),
"model": data.get("image_model") or str(args.get("model") or ""),
"exit_code": 0,
}
if _incognito(ctx):
info["image_url"] = data_url
else:
try:
info.update(_persist_png(_data_url_bytes(data_url), owner, ctx.get("session_id"), prompt, "generate_image"))
except Exception as exc:
logger.warning("generate_image persist failed: %s", exc)
if bool(args.get("place", True)):
placed = await _place_layer(
data_url,
str(args.get("name") or "Generated image"),
owner,
session,
native_size=True,
masked=bool(args.get("mask")),
blend=str(args.get("blend") or "normal"),
opacity=float(args.get("opacity") or 100),
)
info["placed"] = placed
if placed:
info["output"] += " Placed it as a new layer."
return "generate_image", info
File diff suppressed because it is too large Load Diff
+205 -40
View File
@@ -179,8 +179,13 @@ def _resolve_model(spec: str, owner: Optional[str] = None, model_type: Optional[
# served internal id. Include pinned/cached ids in the match set
# so chat sessions using the HF repo id still resolve. Do not use
# stale cached aliases when the endpoint itself is unreachable.
if model_type == "image" and endpoint_reachable:
for extra in _json_list(getattr(ep, "pinned_models", None)) + _json_list(getattr(ep, "cached_models", None)):
if endpoint_reachable:
# Gateway image models may live in a dedicated catalogue,
# so explicitly pinned IDs are valid on mixed endpoints too.
extras = _json_list(getattr(ep, "pinned_models", None))
if model_type == "image":
extras += _json_list(getattr(ep, "cached_models", None))
for extra in extras:
if extra not in model_ids:
model_ids.append(extra)
@@ -965,7 +970,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
elif action == "open_panel":
# Open a top-level panel/modal: documents/library, gallery,
# calendar, email, sessions, notes, memories, skills, settings, theme, cookbook.
# calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook.
panel = parts[1].lower() if len(parts) > 1 else ""
view = ""
view_label = ""
@@ -993,6 +998,8 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
"note": "notes",
"todo": "notes",
"todos": "notes",
"tasks": "tasks",
"task": "tasks",
"memories": "memories",
"memory": "memories",
"brain": "memories",
@@ -1010,7 +1017,7 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
}
target = _panel_aliases.get(panel)
if not target:
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, memories, skills, settings, theme, cookbook."}
return {"error": f"Unknown panel '{panel}'. Valid: documents, gallery, calendar, email, sessions, notes, tasks, memories, skills, settings, theme, cookbook."}
if target == "cookbook":
cookbook_views = {
"models": ("Search", "models"), "model": ("Search", "models"),
@@ -1155,14 +1162,90 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
# Image generation
# ---------------------------------------------------------------------------
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
# Message used whenever a generative image edit (object add/remove/replace,
# image generation) is requested but no image provider is configured. The
# photo-editor agent and the image tools surface this verbatim so the user gets
# a plain, actionable explanation instead of a silent substitute edit.
IMAGE_PROVIDER_REQUIRED_MESSAGE = (
"Removing/adding objects needs an image generation provider; enable one in "
"Settings > Models."
)
def image_editing_provider_available(owner: Optional[str] = None) -> bool:
"""True when an enabled, owner-visible image endpoint can serve AI edits.
Generative operations (object add/remove/replace, image generation) require
a configured image model. Local transforms (remove-bg, upscale, denoise,
enhance-face) do not, but the photo-editor agent gates the whole generative
request on this so it never falls back to deterministic approximations.
"""
try:
from src.settings import get_user_setting, load_settings
if not load_settings().get("image_gen_enabled", True) or not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
return False
except Exception:
pass
try:
from src.database import SessionLocal, ModelEndpoint
from src.auth_helpers import owner_filter
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True, # noqa: E712
ModelEndpoint.model_type == "image",
)
q = owner_filter(q, ModelEndpoint, owner)
return q.first() is not None
finally:
db.close()
except Exception:
return False
def _uses_openrouter_chat_images(model_id: str) -> bool:
# GPT-5 Image is an LLM wrapper, unlike native gpt-image-* image models.
leaf = model_id_leaf(model_id).lower()
return leaf.startswith("gpt-") and "-image" in leaf and not leaf.startswith("gpt-image-")
def _chat_image_results(data: dict) -> list:
images = []
for choice in data.get("choices") or []:
message = choice.get("message") or {}
for item in message.get("images") or []:
url = (item.get("image_url") or {}).get("url")
if not isinstance(url, str):
continue
if url.startswith("data:image/") and ";base64," in url:
images.append({"b64_json": url.split(",", 1)[1]})
elif url.startswith(("https://", "http://")):
images.append({"url": url})
return images
def _image_response_error(data: dict, model_id: str) -> Optional[str]:
errors = [data.get("error")]
errors.extend(choice.get("error") for choice in data.get("choices") or [])
for error in errors:
if not error:
continue
message = str(error.get("message") or "Image provider failed") if isinstance(error, dict) else str(error)
hint = " Choose a currently supported image model in AI settings." if "deprecated" in message.lower() else ""
return f"Image provider failed for '{model_id}': {message}{hint}"
return None
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
"""Generate an image using an image-capable model (e.g. gpt-image-1).
Content format:
Line 1: prompt describing the image
Line 2: model name (optional, default auto-detects: prefers gpt-image-1.5 > gpt-image-1)
Line 3: size (optional, defaults to 1024x1024)
Line 4: quality (optional, defaults to medium — options: low, medium, high, auto)
Line 4: quality (optional, defaults to configured quality or medium — low, medium, high, auto)
"""
import base64
import httpx
@@ -1180,32 +1263,38 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
prompt = str(args.get('prompt') or '').strip()
model_spec = str(args.get('model') or '').strip()
size = str(args.get('size') or '1024x1024')
quality = str(args.get('quality') or 'medium')
quality = str(args.get('quality') or '')
else:
lines = content.strip().split("\n")
prompt = lines[0].strip() if lines else ""
model_spec = lines[1].strip() if len(lines) > 1 and lines[1].strip() else ""
size = lines[2].strip() if len(lines) > 2 and lines[2].strip() else "1024x1024"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else "medium"
quality = lines[3].strip() if len(lines) > 3 and lines[3].strip() else ""
if not prompt:
return {"error": "Image prompt is required (line 1)"}
# Load admin settings for defaults
# Load settings for defaults
try:
from src.settings import load_settings
from src.settings import get_user_setting, load_settings
_settings = load_settings()
except Exception:
_settings = {}
def get_user_setting(key, owner="", default=None):
return default
# The administrator's global image-generation disable switch is authoritative.
# Non-admin preferences cannot re-enable generation when disabled globally.
if not _settings.get("image_gen_enabled", True):
return {"error": "Image generation is disabled by the administrator."}
if not get_user_setting("image_gen_enabled", owner=owner or "", default=True):
return {"error": "Image generation is disabled by user preference."}
# Use admin-configured model/quality if not specified by the tool call
# Use user- or admin-configured model/quality if not specified by the tool call
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
model_spec = get_user_setting("image_model", owner=owner, default="") or ""
if not quality:
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
# Auto-detect best available image model if still not set
if not model_spec:
@@ -1255,6 +1344,14 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
async def _resolve_image_model(model_name: str):
def _call():
# Try a plain by-name lookup first. Gateway endpoints (OpenRouter)
# register as `llm` yet serve image models, and the image-typed
# query's last-resort fallback would otherwise hijack a cloud model
# onto the local image endpoint (gpt-5-image -> qwen-image-edit).
try:
return _resolve_model(model_name, owner=owner)
except ValueError:
pass
try:
return _resolve_model(model_name, owner=owner, model_type="image")
except TypeError as exc:
@@ -1289,8 +1386,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
base_url = url.replace("/chat/completions", "").replace("/v1/messages", "").rstrip("/")
images_url = base_url + "/images/generations"
from src.model_capability_readers.base import detect_vendor
chat_images = detect_vendor(url) == "openrouter" and _uses_openrouter_chat_images(model_id)
if detect_vendor(url) == "openrouter":
images_url = base_url + "/images"
if chat_images:
images_url = base_url + "/chat/completions"
# Capture the requested geometry before older OpenAI size restrictions.
from src.image_model_ids import image_aspect_ratio
try:
requested_width, requested_height = (int(value) for value in size.lower().split("x"))
requested_ratio = image_aspect_ratio(requested_width, requested_height)
except (TypeError, ValueError):
requested_ratio = "1:1"
# Validate size for cloud image models (local diffusion accepts any WxH)
valid_gpt_sizes = {"1024x1024", "1024x1536", "1536x1024", "auto"}
@@ -1306,6 +1414,9 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
"n": 1,
"size": size,
}
if detect_vendor(url) == "openrouter":
payload.pop("size", None)
payload["aspect_ratio"] = requested_ratio
# GPT image models and local diffusion support quality; DALL-E does not
if is_gpt_image or is_local_diffusion:
@@ -1314,12 +1425,21 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
else:
payload["quality"] = "medium"
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={prompt[:80]}")
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={'[private]' if incognito else prompt[:80]}")
try:
# GPT image models can take 30-120s+ depending on quality
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=300.0, write=30.0, pool=30.0)) as client:
resp = await client.post(images_url, json=payload, headers=headers)
request_payload = payload
if chat_images:
request_payload = {
"model": model_id,
"messages": [{"role": "user", "content": prompt}],
"modalities": ["image", "text"],
"stream": False,
"image_config": {"aspect_ratio": requested_ratio},
}
resp = await client.post(images_url, json=request_payload, headers=headers)
if resp.status_code != 200:
error_text = resp.text[:500]
@@ -1334,9 +1454,15 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
}
data = resp.json()
images = data.get("data", [])
provider_error = _image_response_error(data, model_id)
if provider_error:
return {"error": provider_error, "untrusted_content": True}
images = _chat_image_results(data) if chat_images else data.get("data", [])
if not images:
return {"error": "No images returned from API"}
return {"error": (
f"Image model '{model_id}' returned no image. "
"Choose another image generation model in AI settings."
)}
img = images[0]
image_url = None
@@ -1365,18 +1491,22 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
logger.warning(f"Failed to save gallery record: {_ge}")
return ""
# GPT image models always return b64_json; DALL-E may return url
if img.get("b64_json"):
def _store_image(image_bytes):
if incognito:
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
img_path.write_bytes(image_bytes)
if owner:
from src.generated_images import register_generated_image
register_generated_image(filename, owner)
image_url = f"/api/generated-image/{filename}"
image_id = _save_to_gallery(filename)
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
# Private results stay in the response, never in the gallery or on disk.
if img.get("b64_json"):
image_url, image_id = _store_image(base64.b64decode(img['b64_json']))
elif img.get("url"):
# Download external URL and save locally (DALL-E returns temp URLs)
@@ -1390,16 +1520,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
try:
dl_resp = httpx.get(result_url, timeout=60)
if dl_resp.status_code == 200:
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}.png"
img_path = img_dir / filename
img_path.write_bytes(dl_resp.content)
if owner:
from src.generated_images import register_generated_image
register_generated_image(filename, owner)
image_url = f"/api/generated-image/{filename}"
image_id = _save_to_gallery(filename)
image_url, image_id = _store_image(dl_resp.content)
else:
image_url = result_url # fallback to external URL
except Exception as _dl_e:
@@ -1434,8 +1555,9 @@ async def do_edit_image(
session_id: Optional[str] = None,
owner: Optional[str] = None,
size: str = "auto",
quality: str = "medium",
quality: Optional[str] = None,
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
incognito: bool = False,
) -> Dict:
"""Edit an uploaded image using the configured image endpoint."""
import base64
@@ -1453,21 +1575,27 @@ async def do_edit_image(
return {"error": "Attached image file was not found"}
try:
from src.settings import load_settings
from src.settings import get_user_setting, load_settings
_settings = load_settings()
except Exception:
_settings = {}
def get_user_setting(key, owner="", default=None):
return default
if not model_spec:
model_spec = _settings.get("image_model", "")
if quality == "medium" and _settings.get("image_quality"):
quality = _settings["image_quality"]
model_spec = get_user_setting("image_edit_model", owner=owner) or get_user_setting("image_model", owner=owner, default="")
if quality is None:
quality = get_user_setting("image_quality", owner=owner, default="medium") or "medium"
if not model_spec:
return {"error": "No image model selected for image editing"}
try:
try:
def _call():
try:
return _resolve_model(model_spec, owner=owner)
except ValueError:
pass
try:
return _resolve_model(model_spec, owner=owner, model_type="image")
except TypeError as exc:
@@ -1480,6 +1608,7 @@ async def do_edit_image(
except ValueError:
return {"error": f"No endpoint found with image model '{model_spec}'."}
requested_edit_size = size
if not size or size == "auto":
from PIL import Image
from src.image_model_ids import image_edit_size
@@ -1509,7 +1638,7 @@ async def do_edit_image(
request_id = uuid.uuid4().hex
payload["request_id"] = request_id
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, '[private]' if incognito else path.name, '[private]' if incognito else prompt[:80])
def _save_edited_image_to_gallery(filename: str) -> str:
try:
@@ -1539,6 +1668,9 @@ async def do_edit_image(
from PIL import Image
with Image.open(BytesIO(image_bytes)) as output:
size = f"{output.width}x{output.height}"
image_mime = Image.MIME.get(output.format, 'image/png')
if incognito:
return 'data:' + image_mime + ';base64,' + base64.b64encode(image_bytes).decode('ascii'), ''
img_dir = Path(GENERATED_IMAGES_DIR)
img_dir.mkdir(parents=True, exist_ok=True)
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
@@ -1652,6 +1784,34 @@ async def do_edit_image(
"image_url": {"url": f"data:{mime};base64,{image_b64}"},
}],
}
from src.image_model_ids import image_aspect_ratio
from PIL import Image as ReferenceImage
with ReferenceImage.open(path) as reference:
reference_width, reference_height = reference.size
if reference.getexif().get(274) in {5, 6, 7, 8}:
reference_width, reference_height = reference_height, reference_width
if requested_edit_size and requested_edit_size != "auto":
try:
reference_width, reference_height = (int(value) for value in requested_edit_size.lower().split("x"))
except (TypeError, ValueError):
pass
edit_payload.pop("size", None)
edit_payload["aspect_ratio"] = image_aspect_ratio(reference_width, reference_height)
if _uses_openrouter_chat_images(model_id):
edit_payload = {
"model": model_id,
"messages": [{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {
"url": f"data:{mime};base64,{image_b64}",
}},
]}],
"modalities": ["image", "text"],
"stream": False,
"image_config": {"aspect_ratio": edit_payload["aspect_ratio"]},
}
resp = await client.post(base_url + "/chat/completions", json=edit_payload, headers=headers)
else:
resp = await client.post(base_url + "/images", json=edit_payload, headers=headers)
else:
with path.open("rb") as f:
@@ -1695,7 +1855,12 @@ async def do_edit_image(
}
data = resp.json()
images = data.get("data", [])
provider_error = _image_response_error(data, model_id)
if provider_error:
return {"error": provider_error, "untrusted_content": True}
images = (_chat_image_results(data)
if is_openrouter and _uses_openrouter_chat_images(model_id)
else data.get("data", []))
if not images:
return {"error": "No image returned from edit API"}
+3 -2
View File
@@ -25,7 +25,7 @@ def compact_browser_observation(value, budget=8000):
# Snapshot text already contains labels and refs in DOM order.
# The refs mapping repeats them and buries menus in raw JSON.
lines = [line for line in snapshot.splitlines()
if not re.fullmatch(r'\s*-?\s*generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
if not re.fullmatch(r'\s*(?:-\s*)?generic(?:\s+\[ref=e\d+\])?:?\s*', line)]
pages.append(('URL: ' + str(url) + '\n' if url else '') + '\n'.join(lines))
elif url:
notices.append('URL: ' + str(url))
@@ -37,7 +37,8 @@ def compact_browser_observation(value, budget=8000):
parsed = json.loads(item)
except (ValueError, TypeError):
# CLI status text precedes the JSON post-interaction state.
parts = re.split(r'\n\n\[(?:post-[^\]]+|page state after failed [^\]]+)\]\n', item)
# Labels are one line; stopping at \n keeps the split linear.
parts = re.split(r'\n\n\[(?:post-[^\]\n]+|page state after failed [^\]\n]+)\]\n', item)
if len(parts) > 1:
for part in parts:
visit(part)
+139 -3
View File
@@ -10,7 +10,7 @@ import os
import json
import re
import time
from datetime import datetime
from datetime import datetime, timezone
from typing import Tuple
from src.auth_helpers import owner_filter
@@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
return str(e), False
def _daily_brief_utc_boundary(local_dt, tz=None):
"""Convert a local wall-clock boundary using the offset at that boundary."""
if local_dt.tzinfo is None and tz is not None:
local_dt = local_dt.replace(tzinfo=tz)
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
def _daily_brief_local_start(event, tz=None):
"""Keep date-only/floating rows local; convert only stored UTC instants."""
start = event.dtstart
if event.is_utc and not event.all_day:
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
return start.astimezone(tz).replace(tzinfo=None)
return start
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
"""Build a short morning digest: today's calendar events, unread email count
+ top-N senders/subjects, active todos."""
@@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
from routes.email_helpers import _imap_connect, _decode_header
from sqlalchemy import and_, or_
# ----- Calendar: today's events -----
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
tomorrow = today + _td(days=1)
utc_today = _daily_brief_utc_boundary(today)
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
# v2 review HIGH-12: gate the OR-null branch on single-user
# (unconfigured) deploys only. In a multi-user deploy, one
# user's daily brief must not include another user's notes or
@@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
db = SessionLocal()
try:
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
or_(
and_(
CalendarEvent.is_utc.is_(True),
CalendarEvent.all_day.is_not(True),
CalendarEvent.dtstart < utc_tomorrow,
CalendarEvent.dtend > utc_today,
),
and_(
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
CalendarEvent.dtstart < tomorrow,
CalendarEvent.dtend > today,
),
),
CalendarEvent.status != "cancelled",
)
if owner:
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
events = ev_q.order_by(CalendarEvent.dtstart).all()
events = ev_q.all()
events.sort(key=_daily_brief_local_start)
# ----- Notes: pinned + non-archived todos with at least one undone item -----
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
if owner:
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
if events:
plain.append("Calendar:")
for e in events:
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
loc = f" @ {e.location}" if e.location else ""
plain.append(f" {t} {e.summary}{loc}")
plain.append("")
@@ -2465,6 +2497,100 @@ async def action_ping_notes(owner: str, **kwargs) -> Tuple[str, bool]:
return str(e), False
async def _spec_triage_email_urgency(item: dict, *, triage_version: int) -> dict | None:
"""Classify one email's urgency with the trained jeff ``triage`` adapter.
Returns an email-urgency verdict (same shape as the LLM path) or None when
the per-job flag is off or the adapter is unavailable, so the caller falls
through to the LLM candidate chain. Only the ``urgency`` question is sent:
jeff scores each question in its own forward pass, and that is the question
this adapter was trained on.
"""
from src.jeff_decision import jeff_job_enabled, jeff_systemone, score_value
if not jeff_job_enabled("EMAIL_URGENCY"):
return None
message = f"Subject: {item.get('subject','')}\n\n{item.get('body','')}".strip()
if not message:
return None
answers = await jeff_systemone(
"triage",
{"channel": "email", "message": message[:8000]},
{"urgency": {
"type": "score",
"instructions": "How urgently does this need a response?",
"criteria": [
"No action needed",
"Can wait a few days",
"Handle within a day",
"Handle within hours",
"Handle immediately",
],
}},
)
if not answers:
return None
exp = score_value(answers.get("urgency"))
if exp is None:
return None
# Adapter's 0-4 urgency scale -> the app's 0-3 score.
score = max(0, min(3, int(round(exp))))
reason = "triage adapter"
# Same high-precision correction the prompt path applies: a person waiting
# in person is always urgent, whatever the model thinks.
blob = f"{item.get('headers','')}\n{item.get('subject','')}\n{item.get('body','')}".lower()
if re.search(r"\b(i'?m|i am|im|we'?re|we are)\s+outside\b", blob) or re.search(
r"\b(waiting outside|at the door|locked out|can'?t get in|cannot get in)\b", blob
):
if score < 3:
reason = "person is waiting outside"
score = 3
# Same bulk/marketing guard the prompt path applies: a newsletter or
# promotion is not urgent no matter how time-limited it sounds.
bulkish = bool(re.search(
r"\b(list-unsubscribe|list-id|mailchimp|mailchimpapp|view this email in your browser|unsubscribe|newsletter|digest|precedence:\s*bulk)\b",
blob,
))
# The adapter was trained on support tickets, so it over-rates promotions.
# Require a clear bulk header or several marketing terms before zeroing.
marketing_hits = sum(1 for t in (
"advertisement", "sponsored", "promo", "promotion", "sale", "discount",
"offer", "limited time", "deal", "coupon", "shop now", "buy now",
"sold out", "low tickets",
) if t in blob)
if bulkish or marketing_hits >= 2:
score = 0
reason = "bulk mail; no personal reply needed"
tags = []
if score >= 3:
tags.append("urgent")
elif score >= 2:
tags.append("reply-soon")
_from_raw = item.get("from", "") or ""
if "<" in _from_raw:
_from_short = _from_raw.split("<", 1)[0].strip().strip('"') or _from_raw
else:
_from_short = _from_raw
return {
"score": score,
"tags": tags[:4],
"spam": False,
"reason": reason,
"subject": (item.get("subject") or "")[:200],
"from": _from_short[:120],
"triage_version": triage_version,
"message_id": (item.get("message_id") or "").strip(),
"unread": bool(item.get("unread")),
"ts": time.time(),
}
async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
"""Scan unread emails across all accounts, LLM-triage new ones, cache
per-UID verdicts, tag the inbox, and fire a reminder when a previously
@@ -2852,6 +2978,16 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
per_uid_scores[key] = verdict
saved_classifications += 1
continue
# ── Spec decision adapter (triage): a calibrated urgency score
# with no generated-text prompt. Enabled per-job via
# ODYSSEUS_JEFF_EMAIL_URGENCY; any failure returns None and the
# LLM candidate chain below still runs.
_tri = await _spec_triage_email_urgency(item, triage_version=TRIAGE_VERSION)
if _tri is not None:
cache.setdefault("uids", {})[item["uid"]] = _tri
per_uid_scores[key] = _tri
saved_classifications += 1
continue
# ── LLM-classify. JSON-only response; bullet-proof parse.
llm_attempts += 1
prompt = (
+12 -1
View File
@@ -211,10 +211,15 @@ class ChatHandler:
from src.settings import get_setting
vision_enabled = get_setting("vision_enabled", True)
if vision_enabled:
from src.owner_identity import auth_disabled
sess_owner = getattr(sess, "owner", None)
if not sess_owner and not auth_disabled():
sess_owner = ""
main_is_vision = await asyncio.to_thread(
model_supports_vision,
sess.model or "",
getattr(sess, "endpoint_url", "") or "",
sess_owner,
)
if effective_att_ids and vision_enabled:
@@ -277,7 +282,13 @@ class ChatHandler:
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
except Exception:
pass
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
if vl_desc:
# A separate vision call wrote this, but the chat model sees
# one message and answers the prose as if the user wrote it
# (#5573). Label it below the marker line — the client strips
# the block by matching `[Image: <name>]\n`.
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
# Surface the description to the client live so it renders as a
# collapsible "image description" on the user bubble (not just
# after a refresh that re-parses the stored message).
+328 -21
View File
@@ -5,9 +5,14 @@ import re
import os
import json
import time
import socket
import ipaddress
import logging
import httpx
import typing
import httpcore
from httpcore._backends.sync import SyncBackend, SyncStream
from httpcore._exceptions import map_exceptions, ConnectTimeout, ConnectError
from urllib.parse import urlparse
from fastapi import HTTPException
from fastapi import UploadFile
@@ -17,6 +22,9 @@ from src.upload_limits import format_byte_limit, get_chat_upload_max_bytes
logger = logging.getLogger(__name__)
_ORIGINAL_HTTPX_GET = httpx.get
_ORIGINAL_HTTPX_POST = httpx.post
def extract_urls(text: str) -> List[str]:
"""Extract URLs from text using regex pattern."""
@@ -81,6 +89,16 @@ def is_vision_model(model_name: str) -> bool:
drops the image entirely. See issue #124.
"""
m = (model_name or "").lower()
if not m:
return False
# Per-model override (comma-separated substrings), e.g. a local model whose
# name carries no vision/VL hint but answers images natively.
override = os.getenv("ODYSSEUS_VISION_MODELS", "")
if override:
for token in override.split(","):
token = token.strip().lower()
if token and token in m:
return True
if any(kw in m for kw in _VISION_MODEL_KEYWORDS):
return True
return bool(_VISION_VL_RE.search(m))
@@ -91,23 +109,167 @@ _PROVIDER_FINGERPRINT_TTL = 60.0
_lmstudio_models_cache: dict = {}
def _is_local_host(host: Optional[str]) -> bool:
"""True for loopback/LAN/Tailscale hosts (never public domains)."""
host = (host or "").lower()
_BLOCKED_METADATA_IPS = {
ipaddress.ip_address("169.254.169.254"),
ipaddress.ip_address("169.254.169.253"),
ipaddress.ip_address("100.100.100.200"),
}
_CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10")
_NAT64_PREFIX = ipaddress.ip_network("64:ff9b::/96")
def _is_ip_safe_local(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
# IPv4-mapped IPv6 (e.g. ::ffff:169.254.169.254) — unwrap to evaluate the embedded IPv4
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
ip = ip.ipv4_mapped
elif isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_PREFIX:
ip = ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
if ip in _BLOCKED_METADATA_IPS:
return False
if ip.is_link_local:
return False
# Explicit loopback check before reserved rejection:
# RFC 4291 / CPython marks IPv6 ::1 as both is_loopback and is_reserved.
if ip.is_loopback:
return True
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
return False
if ip.is_private:
return True
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NETWORK:
return True
return False
def _is_safe_local_host(host: Optional[str]) -> bool:
"""True for loopback/LAN/Tailscale hosts.
Explicitly excludes link-local, cloud metadata destinations (AWS/GCP/Azure/Alibaba),
unresolved single-label names, and public domains.
"""
host = (host or "").strip().lower()
if not host:
return False
if host in {"localhost", "host.docker.internal"} or host.endswith(".local"):
return True
# Strip optional IPv6 square brackets: [::1] -> ::1
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
try:
ip = ipaddress.ip_address(host)
ip = ipaddress.ip_address(literal)
return _is_ip_safe_local(ip)
except ValueError:
return "." not in host
if ip.is_loopback or ip.is_private or ip.is_link_local:
return True
return ip in ipaddress.ip_network("100.64.0.0/10")
pass
allowed_names = {"localhost", "host.docker.internal"}
if not (host in allowed_names or host.endswith(".local") or host.endswith(".internal") or host.endswith(".lan")):
return False
try:
infos = socket.getaddrinfo(host, None)
if not infos:
return False
resolved_ips = []
for info in infos:
sockaddr = info[4]
ip_str = str(sockaddr[0]).split("%")[0]
resolved_ips.append(ipaddress.ip_address(ip_str))
if not resolved_ips:
return False
return all(_is_ip_safe_local(ip) for ip in resolved_ips)
except Exception:
return False
def _probe_lmstudio_models(url: str) -> Optional[list]:
_is_local_host = _is_safe_local_host
class SafeProbeBackend(SyncBackend):
"""Enforces SSRF address policies at TCP connection time.
Resolves the host once, evaluates every resolved address against _is_ip_safe_local,
and connects directly to the pre-validated IP, eliminating DNS TOCTOU and rebinding.
Preserves TLS SNI and hostname certificate validation via SyncStream.start_tls.
"""
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options: typing.Iterable[httpcore.SOCKET_OPTION] | None = None,
) -> httpcore.NetworkStream:
source_address = None if local_address is None else (local_address, 0)
exc_map = {socket.timeout: ConnectTimeout, OSError: ConnectError}
with map_exceptions(exc_map):
infos = socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM)
if not infos:
raise ConnectError(f"Host {host} did not resolve")
for family, socktype, proto, _canon, sockaddr in infos:
ip_str = str(sockaddr[0]).split("%")[0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
raise ConnectError(f"Invalid resolved IP: {ip_str}")
if not _is_ip_safe_local(ip):
raise ConnectError(f"Target address is not an authorized safe local destination: {ip}")
last_err: Optional[OSError] = None
for family, socktype, proto, _canon, sockaddr in infos:
sock = socket.socket(family, socktype, proto)
try:
if timeout is not None:
sock.settimeout(timeout)
if source_address:
sock.bind(source_address)
sock.connect(sockaddr)
if socket_options:
for opt in socket_options:
sock.setsockopt(*opt)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
return SyncStream(sock)
except OSError as e:
last_err = e
sock.close()
if last_err is not None:
raise last_err
raise ConnectError(f"Could not connect to {host}:{port}")
def _safe_probe_get(url: str, *, headers: Optional[dict] = None, timeout: float = 1.0) -> httpx.Response:
if httpx.get is not _ORIGINAL_HTTPX_GET:
try:
if headers:
return httpx.get(url, headers=headers, timeout=timeout, follow_redirects=False)
return httpx.get(url, timeout=timeout, follow_redirects=False)
except TypeError:
if headers:
return httpx.get(url, headers=headers, timeout=timeout)
return httpx.get(url, timeout=timeout)
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
transport = httpx.HTTPTransport(trust_env=False)
transport._pool = pool
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
return client.get(url, headers=headers, timeout=timeout)
def _safe_probe_post(url: str, *, json: typing.Any = None, headers: Optional[dict] = None, timeout: float = 8.0) -> httpx.Response:
if httpx.post is not _ORIGINAL_HTTPX_POST:
try:
if headers:
return httpx.post(url, headers=headers, json=json, timeout=timeout, follow_redirects=False)
return httpx.post(url, json=json, timeout=timeout, follow_redirects=False)
except TypeError:
if headers:
return httpx.post(url, headers=headers, json=json, timeout=timeout)
return httpx.post(url, json=json, timeout=timeout)
pool = httpcore.ConnectionPool(network_backend=SafeProbeBackend())
transport = httpx.HTTPTransport(trust_env=False)
transport._pool = pool
with httpx.Client(transport=transport, follow_redirects=False, trust_env=False) as client:
return client.post(url, headers=headers, json=json, timeout=timeout)
def _probe_lmstudio_models(url: str, headers: Optional[dict] = None) -> Optional[list]:
"""Return LM Studio's native /api/v1/models list, or None when the endpoint
isn't LM Studio or is unreachable (short-TTL cached; transient errors uncached)."""
parsed = urlparse(url)
@@ -117,10 +279,10 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
cached = _lmstudio_models_cache.get(key)
if cached is not None and cached[1] > now:
return cached[0]
authority = host if parsed.port is None else f"{host}:{parsed.port}"
authority = parsed.netloc or (f"[{host}]:{parsed.port}" if (host and ":" in host and not host.startswith("[")) else (f"{host}:{parsed.port}" if (host and parsed.port is not None) else (host or "")))
probe_url = f"{parsed.scheme or 'http'}://{authority}/api/v1/models"
try:
r = httpx.get(probe_url, timeout=1.0)
r = _safe_probe_get(probe_url, headers=headers, timeout=1.0)
except Exception:
return None
try:
@@ -138,15 +300,15 @@ def _probe_lmstudio_models(url: str) -> Optional[list]:
return models
def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
def lmstudio_supports_vision(url: str, model: str, headers: Optional[dict] = None) -> Optional[bool]:
"""Read `model`'s capabilities.vision flag from LM Studio, or None when the
endpoint isn't LM Studio or doesn't report it (so callers fall back)."""
if not model:
return None
# Never probe a remote provider; LM Studio is always a local/LAN host.
if not _is_local_host(urlparse(url).hostname):
# Never probe a remote provider or disallowed host; LM Studio is always a local/LAN host.
if not _is_safe_local_host(urlparse(url).hostname):
return None
models = _probe_lmstudio_models(url)
models = _probe_lmstudio_models(url, headers=headers)
if not models:
return None
want = model.strip().lower()
@@ -162,20 +324,165 @@ def lmstudio_supports_vision(url: str, model: str) -> Optional[bool]:
return None
def model_supports_vision(model_name: str, endpoint_url: str = "") -> bool:
def model_supports_vision(
model_name: str,
endpoint_url: str = "",
owner: Optional[str] = None,
) -> bool:
"""Whether a model accepts images, using the endpoint's reported
capability when available (LM Studio) and falling back to name-based
detection otherwise."""
capability when available (LM Studio), a cached one-time probe for local
OpenAI-compatible endpoints, and name-based detection otherwise."""
if is_vision_model(model_name):
return True
if endpoint_url:
target_url = endpoint_url
headers: Optional[dict] = None
if owner is not None:
if not owner:
# Authentication enabled, but caller has no owner: refuse probing immediately
return is_vision_model(model_name)
try:
advertised = lmstudio_supports_vision(endpoint_url, model_name or "")
from src.database import SessionLocal
from src.endpoint_resolver import (
normalize_base,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
with SessionLocal() as db:
ep = resolve_owner_registered_endpoint(db, endpoint_url, owner)
resolved_base, api_key = resolve_endpoint_runtime(ep)
target_url = normalize_base(resolved_base or ep.base_url)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
except ValueError:
# Foreign-owned, disabled, or unregistered endpoint
return is_vision_model(model_name)
except Exception as e:
logger.warning("Endpoint authorization check failed: %s", e)
return is_vision_model(model_name)
else:
# Single-user / auth-disabled mode (owner is None):
try:
from src.database import SessionLocal
from src.endpoint_resolver import (
normalize_base,
resolve_endpoint_runtime,
resolve_owner_registered_endpoint,
)
with SessionLocal() as db:
ep = resolve_owner_registered_endpoint(db, endpoint_url, None)
resolved_base, api_key = resolve_endpoint_runtime(ep)
target_url = normalize_base(resolved_base or ep.base_url)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
except Exception:
# In single-user mode, an unregistered endpoint is allowed
# only if target URL passes safe local host checks (probed with headers=None).
target_url = endpoint_url
headers = None
try:
if headers:
advertised = lmstudio_supports_vision(target_url, model_name or "", headers=headers)
else:
advertised = lmstudio_supports_vision(target_url, model_name or "")
except TypeError:
advertised = lmstudio_supports_vision(target_url, model_name or "")
except Exception:
advertised = None
if advertised is not None:
return advertised
try:
if headers:
probed = probe_endpoint_supports_vision(target_url, model_name or "", headers=headers)
else:
probed = probe_endpoint_supports_vision(target_url, model_name or "")
except TypeError:
probed = probe_endpoint_supports_vision(target_url, model_name or "")
except Exception:
probed = None
if probed is not None:
return probed
return is_vision_model(model_name)
# 1x1 red PNG — the cheapest possible image probe payload.
_PROBE_IMAGE_DATA_URL = (
"data:image/png;base64,"
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
)
_VISION_PROBE_TTL = 3600.0
# (host, model) -> (bool | None, expiry); None = undecidable (auth/network).
_vision_probe_cache: dict = {}
def _vision_probe_enabled() -> bool:
return os.getenv("ODYSSEUS_VISION_PROBE", "1").strip().lower() not in ("0", "false", "off", "no")
def _chat_completions_url(endpoint_url: str) -> str:
base = (endpoint_url or "").rstrip("/")
if base.endswith("/chat/completions"):
return base
return base + "/chat/completions"
def probe_endpoint_supports_vision(
endpoint_url: str,
model_name: str,
headers: Optional[dict] = None,
) -> Optional[bool]:
"""One-time (cached) probe: does a local OpenAI-compatible endpoint accept
an image_url content part for `model_name`?
Returns True on a 200, False when the endpoint rejects the image (400/413/
415/422), and None when we cannot decide (remote host, auth required,
unreachable, or probing disabled) so the caller falls back to name rules.
"""
if not endpoint_url or not model_name or not _vision_probe_enabled():
return None
parsed = urlparse(endpoint_url)
if not _is_safe_local_host(parsed.hostname):
return None # never probe a public provider or disallowed host
key = ((parsed.hostname or "").lower(), (parsed.port or 0), model_name.strip().lower())
now = time.time()
cached = _vision_probe_cache.get(key)
if cached is not None and cached[1] > now:
return cached[0]
payload = {
"model": model_name,
"max_tokens": 1,
"stream": False,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "Reply with one word."},
{"type": "image_url", "image_url": {"url": _PROBE_IMAGE_DATA_URL}},
],
}],
}
verdict: Optional[bool] = None
try:
r = _safe_probe_post(
_chat_completions_url(endpoint_url),
headers=headers,
json=payload,
timeout=8.0,
)
if r.status_code == 200:
verdict = True
elif r.status_code in (400, 413, 415, 422):
body = (r.text or "").lower()
if any(tok in body for tok in ("image", "vision", "multimodal", "content type")):
verdict = False
else:
verdict = False
except Exception:
verdict = None
_vision_probe_cache[key] = (verdict, now + _VISION_PROBE_TTL)
if verdict is not None:
logger.info("vision probe %s@%s -> %s", model_name, parsed.hostname, verdict)
return verdict
def validate_message(message: str) -> str:
"""Validate message input."""
if not message:
+59 -10
View File
@@ -9,9 +9,61 @@ from src.chat_helpers import extract_urls
from src.youtube_handler import is_youtube_url
from src.search import comprehensive_web_search, fetch_webpage_content
from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message
from src.text_helpers import strip_think
logger = logging.getLogger(__name__)
_SEARCH_QUERY_HISTORY_TURNS = 4
_SEARCH_QUERY_HISTORY_TURN_CHARS = 500
def _search_query_messages(message: str, history: List[Any]) -> List[Dict[str, str]]:
"""Build the query extraction prompt, with recent turns to resolve references.
A follow-up such as "Does he work with Jane?" has no searchable subject on
its own. The current turn is already the last history entry when the session
persists it, so a trailing user entry is dropped rather than repeated.
"""
turns = [m for m in history if getattr(m, "role", "") in {"user", "assistant"}]
if turns and turns[-1].role == "user":
turns.pop()
lines = []
for turn in turns[-_SEARCH_QUERY_HISTORY_TURNS:]:
content = turn.content
if isinstance(content, list):
content = " ".join(
str(part.get("text") or "") for part in content if isinstance(part, dict)
)
text = " ".join(strip_think(str(content or ""), prose=False, prompt_echo=False).split())
if text:
lines.append(f"{turn.role}: {text[:_SEARCH_QUERY_HISTORY_TURN_CHARS]}")
if not lines:
return [
{
"role": "system",
"content": "Extract a concise search query from the user's message. Reply ONLY with the query.",
},
{"role": "user", "content": message},
]
conversation = "\n".join(lines)
return [
{
"role": "system",
"content": (
"Extract a concise search query from the user's latest message. "
"Use the earlier conversation only to resolve references such as pronouns, "
"and leave out earlier topics the latest message does not refer to. "
"Reply ONLY with the query."
),
},
{
"role": "user",
"content": f"Earlier conversation:\n{conversation}\n\nLatest message:\n{message}",
},
]
def _clean_search_query(query: str, max_len: int = 200) -> str:
"""Strip fenced code blocks from a search query while preserving inline
@@ -422,21 +474,18 @@ class ChatProcessor:
generated_query = llm_call(
t_url,
t_model,
[
{
"role": "system",
"content": (
"Extract a concise search query from the user's message. "
"Reply ONLY with the query."
_search_query_messages(
message,
[] if incognito else getattr(session, "history", None) or [],
),
},
{"role": "user", "content": message},
],
headers=t_headers,
temperature=0.1,
max_tokens=50,
timeout=15,
).strip()
thinking_mode="off",
)
# Models without a reasoning parser put thinking in the content.
generated_query = strip_think(generated_query, prose=False, prompt_echo=False).strip()
if generated_query:
# LLM successfully generated a non-empty query -> use the generated query
+689 -22
View File
@@ -20,7 +20,11 @@ from pathlib import Path
import httpx
import jsonschema
from src.context_compactor import prune_multimodal_images, trim_for_context
from src.context_compactor import (
keep_recent_image_turns,
prune_multimodal_images,
trim_for_context,
)
from src.agent_runtime.runtime_selection import COMPACT_PREVIEW_MODE
from src import agent_runs
from src.agent_evidence import command_has_mutation_effect, workspace_artifact_is_usable
@@ -42,6 +46,7 @@ from src.turn_contract import (
_REQUEST_PREFIX,
calendar_retiming_request,
FAMILY_TOOLS, broad_web_briefing_request, required_read_operation_for_request,
note_read_intent,
targets_bound_editor_request, inline_text_transformation, editor_request_instructions,
_bound_editor_requests_web_verification, scheduled_automation_request, creation_container_tool,
)
@@ -206,9 +211,17 @@ CONTRACT_REQUIRED_TOOLS = frozenset({
})
from src.turn_contract import CONTRACT_CORE_TOOLS
# Scoped NL photo-editor surface. These are offered only when the
# ``photo_editor`` capability family is active (editor Quick Edit popup or an
# image-edit chat turn); they drive the in-process, owner-scoped editor bridge
# and the existing AI image ops.
EDITOR_AGENT_TOOLS = frozenset({
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
})
PREVIEW_TOOLS = (
READ_TOOLS | SAFE_WRITE_TOOLS | EXPLICIT_EXECUTE_TOOLS | SAFE_UI_TOOLS
| BROKERED_JOB_TOOLS | CONTRACT_REQUIRED_TOOLS | CONTRACT_CORE_TOOLS
| EDITOR_AGENT_TOOLS
)
# Keep a small recovery-capable surface on every interactive compact agent
# turn. Routing still adds domain tools, while policy and action guards remain
@@ -1035,6 +1048,22 @@ def notes_terminal_response(raw, *, user_text='', max_items=20):
return _bounded_structured_list(summary, user_text=user_text)
def note_locator_followup(raw, *, user_text=''):
"""Require a body read before answering a note-content request."""
_, needs_body = note_read_intent(user_text)
if not needs_body or note_search_result_empty(raw):
return ''
listing = notes_terminal_response(raw, user_text=user_text, max_items=5)
match = re.search(r'\(#note-([A-Za-z0-9_-]+)\)', listing)
if not match:
return ''
return (
'The notes result contains titles only, not the body. '
f'Call manage_notes with action="view" and id="{match.group(1)}" now. '
'Use the returned body to answer the request; do not infer content from the title.'
)
def documents_terminal_response(raw, *, user_text='', max_items=8):
"""Render linked document rows under the user's explicit global cap."""
from src.agent_loop import _document_list_summary_from_tool_output
@@ -1200,7 +1229,7 @@ def tasks_terminal_response(raw, *, user_text='', max_items=20):
text = str(payload or '').strip()
rows = []
for line in text.splitlines():
match = re.match(r'^\s*\d+\.\s+(.*?)\s+\(([^)]+)\)\s+—\s+(.+)$', line)
match = re.match(r'^\s*\d+\.\s(.*?)\s\(([^()]+)\)\s+—\s+(.+)$', line)
if match:
details = match[3].strip()
rows.append({
@@ -1317,7 +1346,7 @@ def skills_terminal_response(raw, *, user_text='', max_items=20):
continue
match = re.match(r'^\s*-\s+\*\*([^*]+)\*\*(?:\s+\(([^)]+)\))?', line)
if not match:
match = re.match(r'^\s*-\s+([^:]+?)(?:\s+\(([^)]+)\))?(?:\s*:|$)', line)
match = re.match(r'^\s*-\s+([^\s:][^:]*?)(?:\s\(([^()]+)\)(?:\s*:|$)|:|$)', line)
if match and status:
rows.append((status, match.group(1).strip(), (match.group(2) or '').strip()))
if not rows:
@@ -1368,17 +1397,26 @@ def contentless_final_response(content):
def action_promise_response(content):
"""Recognize a short promise to do work that contains no result yet."""
text = re.sub(r'\s+', ' ', str(content or '')).strip()
if not text or len(text.split()) > 48:
"""Recognize a short promise to do work that contains no result yet.
Fenced code blocks are stripped before the word count and the verb match,
so a promise followed by a code block (e.g. "I will browse Google Maps ..."
plus a requests/BeautifulSoup snippet and no tool call) still counts as an
action promise. The caller only reaches this when no tool call was made.
"""
without_code = re.sub(
r'\s+', ' ', re.sub(r'```.*?```', ' ', str(content or ''), flags=re.DOTALL)
).strip()
if not without_code or len(without_code.split()) > 48:
return False
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', text, re.I):
if re.search(r'\b(?:answer|result|score|served|created|saved)\s*:', without_code, re.I):
return False
return bool(re.match(
r"^(?:okay[,.:]?\s*)?(?:let me|i(?:'ll| will)|next[,.:]?\s+i(?:'ll| will))\s+"
r"(?:now\s+)?(?:inspect|extract|read|check|review|analy[sz]e|verify|open|"
r"search|look|create|write|render|run|use|continue|finish|provide)\b",
text,
r"search|look|create|write|render|run|use|continue|finish|provide|"
r"browse|find|fetch|visit|navigate|go|get|scrape)\b",
without_code,
re.I,
))
@@ -1407,6 +1445,210 @@ def progressive_thinking_for_turn(model, offered_schemas, thinking_mode=None):
return uses_odysseus_progressive_thinking(model) and not bool(offered_schemas)
def spec_router_thinking_decision(endpoint_url, model, owner, headers, direct_user_text):
"""Env-gated router decision for spec tool-surface turns. Fail open (None)."""
try:
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
if not spec_router.think_router_enabled():
return None
from src.agent_loop import _configured_model_tool_surface_raw
if _configured_model_tool_surface_raw(
endpoint_url, model, owner, headers=headers
) != 'spec':
return None
return spec_router.route_thinking(direct_user_text)
except Exception:
logging.getLogger(__name__).warning(
'spec think router decision failed; using rules only', exc_info=True)
return None
async def spec_router_thinking_decision_async(endpoint_url, model, owner, headers, direct_user_text):
"""Async env-gated router decision for spec tool-surface turns. Fail open (None)."""
try:
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
if not spec_router.think_router_enabled():
return None
from src.agent_loop import _configured_model_tool_surface_raw
if _configured_model_tool_surface_raw(
endpoint_url, model, owner, headers=headers
) != 'spec':
return None
if hasattr(spec_router, 'route_thinking_async'):
return await spec_router.route_thinking_async(direct_user_text)
return spec_router.route_thinking(direct_user_text)
except Exception:
logging.getLogger(__name__).warning(
'spec think router decision failed; using rules only', exc_info=True)
return None
def spec_router_think_reason(round_number, decision, existing_deliberate):
"""Add 'router_think' on round 1 when the router asked for thinking and no
other rule already selected a deliberate reason."""
if (round_number == 1 and not existing_deliberate
and isinstance(decision, dict) and decision.get('think')):
return 'router_think'
return ''
def spec_direct_mode():
"""Which direct-answer behaviour to use when the gate is on.
'tool_choice_none' (default) keeps the tools list but forbids a tool call
for the round; 'empty_menu' sends no tools at all. Any other value falls
back to the default.
"""
mode = str(
os.getenv('ODYSSEUS_SPEC_DIRECT_MODE', 'tool_choice_none') or ''
).strip().lower()
return 'empty_menu' if mode == 'empty_menu' else 'tool_choice_none'
def spec_direct_no_tools_p(user_message, endpoint_url, model, owner, headers):
"""Env-gated confidence for a spec 'answer_directly' turn.
Returns the answer_directly probability when the spec router just decided
to answer directly with enough confidence, else None. Default OFF, so the
caller behaves exactly as before unless ODYSSEUS_SPEC_DIRECT_NOTOOLS is set.
"""
if str(os.getenv('ODYSSEUS_SPEC_DIRECT_NOTOOLS', '')).strip().lower() not in (
'1', 'true', 'yes', 'on'):
return None
try:
from src.agent_loop import _configured_model_tool_surface_raw
if _configured_model_tool_surface_raw(
endpoint_url, model, owner, headers=headers
) != 'spec':
return None
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
decision = spec_router.latest_decision()
if not isinstance(decision, dict):
return None
# Guard against a stale decision from a previous turn.
msg = str(user_message or '')
decided_msg = str(decision.get('message') or '')
if decided_msg and msg and decided_msg[:40] != msg[:40]:
return None
if decision.get('choice') != 'answer_directly':
return None
try:
p = float(decision.get('answer_directly') or 0.0)
except (TypeError, ValueError):
return None
try:
min_p = float(os.getenv('ODYSSEUS_SPEC_DIRECT_MIN_P', '0.9') or 0.9)
except (TypeError, ValueError):
min_p = 0.9
return p if p >= min_p else None
except Exception:
logging.getLogger(__name__).warning(
'spec direct-answer decision failed; using rules only', exc_info=True)
return None
def spec_driver_active(endpoint_url, model, owner, headers):
"""True when ODYSSEUS_SPEC_MODE=driver and the turn uses the spec surface."""
if str(os.getenv('ODYSSEUS_SPEC_MODE', '') or '').strip().lower() != 'driver':
return False
try:
from src.agent_loop import _configured_model_tool_surface_raw
return _configured_model_tool_surface_raw(
endpoint_url, model, owner, headers=headers) == 'spec'
except Exception:
return False
def spec_driver_pick(conversation, disabled_tools, user_text, schemas=None):
"""Per-round ODYSSEUS_SPEC_MODE=driver decision: the router drives tools.
Ask the router once for this round and return its TOP-1 choice at ANY
confidence: ``{'choice': 'tool'|'answer_directly'|'ask_user', 'p': float,
'tool_name': str|None}``. Returns None on any router error/timeout or an
unusable pick, so the caller keeps the normal menu for that round.
``schemas`` is the round's offered menu; the router only ever picks from
it, so a top-1 tool is always one the turn can actually execute (passing
every known tool here made the driver name tools outside the offered set,
which then looped on permission errors).
"""
try:
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
import sys as _sys
spec_router = _sys.modules.get('src.spec_router')
if spec_router is None:
from src import spec_router # noqa: F401
menu = list(schemas) if schemas else list(FUNCTION_TOOL_SCHEMAS)
picks, _decided = spec_router.spec_route_tools(
str(user_text or ''), menu,
set(disabled_tools or ()), list(conversation or []),
return_decision=True,
)
decision = spec_router.latest_decision() or {}
if not isinstance(decision, dict) or decision.get('error'):
return None
choice = decision.get('choice')
top = decision.get('top') or []
if choice == 'answer_directly':
return {'choice': 'answer_directly',
'p': float(decision.get('answer_directly') or 0.0),
'tool_name': None}
if choice == 'ask_user':
return {'choice': 'ask_user',
'p': float(decision.get('ask_user') or 0.0),
'tool_name': None}
# Driver mode takes the router's TOP-1 at ANY confidence, so read the
# ranked ``top`` (not the MIN_CONF-filtered ``picks``/``added``).
if top and isinstance(top[0], (list, tuple)) and top[0]:
name = str(top[0][0] or '')
p = None
if len(top[0]) >= 2:
try:
p = float(top[0][1])
except (TypeError, ValueError):
p = None
if name:
return {'choice': 'tool', 'p': p, 'tool_name': name}
if picks:
try:
name = str(next(iter(picks)))
except TypeError:
name = str(picks[0]) if len(picks) else ''
if name:
return {'choice': 'tool', 'p': None, 'tool_name': name}
return None
except Exception:
logging.getLogger(__name__).warning(
'spec driver decision failed; using the normal menu', exc_info=True)
return None
def tool_deliberation_reason(name, args):
"""Classify selected operations, never infer intent from tool availability."""
name = canonical(name)
if name in {'python', 'edit_file', 'apply_patch'}:
return 'code'
if name in {'edit_document', 'suggest_document', 'update_document'}:
return 'document_edit'
if name in {'draft_email', 'draft_email_reply', 'ai_draft_email_reply'}:
return 'email_draft'
action = str(args.get('action') or '').lower()
if name == 'manage_tasks' and action in {'create', 'add', 'update', 'edit'}:
return 'task_design'
if name == 'manage_skills' and action in {'add', 'edit', 'patch', 'publish', 'audit'}:
return 'skill_evaluation'
return ''
def visible_content_after_qwen_thinking(content):
"""Remove private Qwen reasoning when the server lacks a reasoning parser."""
@@ -1422,6 +1664,48 @@ def visible_content_after_qwen_thinking(content):
return text.strip()
def qwen_think_split_events(content, state):
"""Split a no-reasoning-parser thinking round into live deltas.
Some thinking servers (no --reasoning-parser) return the whole
``<think>…</think>`` trace as ordinary ``content``. Buffering that round
made the UI show nothing while the model thought and then dump the whole
answer at once. Stream the trace as ``{'delta': ..., 'thinking': True}``
events (the same shape a reasoning-parser server emits) and the text after
``</think>`` as normal answer deltas. ``state`` is a mutable per-round dict
carrying the emission bookkeeping; the stored answer is unchanged (see
``visible_content_after_qwen_thinking``).
"""
events = []
text = str(content or '')
if not text:
return events
lowered = text.casefold()
lead = len('<think>') if lowered.startswith('<think>') else 0
close = lowered.find('</think>')
if close < 0:
# The opening <think> is injected by the chat template, so a progressive
# trace can arrive with no marker until </think>. Emit the buffered text
# as thinking - holding back a possible partial close tag - so the trace
# streams live instead of dumping in one block at the end.
think_region = text[lead:max(lead, len(text) - len('</think>'))]
answer_region = ''
else:
think_region = text[lead:close]
answer_region = text[close + len('</think>'):]
emitted_think = int(state.get('think_emitted', 0) or 0)
if len(think_region) > emitted_think:
events.append({'delta': think_region[emitted_think:], 'thinking': True})
state['think_emitted'] = len(think_region)
if close >= 0:
state['closed'] = True
emitted_answer = int(state.get('answer_emitted', 0) or 0)
if len(answer_region) > emitted_answer:
events.append({'delta': answer_region[emitted_answer:]})
state['answer_emitted'] = len(answer_region)
return events
def prior_short_answer_for_no_tool_summary(user_text, history):
"""Reuse the immediately prior concise answer for an explicit no-tool recap."""
text = str(user_text or '')
@@ -2714,6 +2998,18 @@ def normalize_preview_entity_anchor_args(name, args):
value = args.get(field)
if isinstance(value, str) and value.startswith(prefix):
args[field] = value[len(prefix):]
if canonical(name) == 'manage_notes':
# Some models copy the rendered note href without its leading '#'.
# Decode only a complete UUID; partial selectors retain the existing
# owner-bound resolution policy instead of gaining a new alias.
value = args.get('note_id')
if isinstance(value, str):
match = re.fullmatch(
r'note-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})',
value, re.I,
)
if match:
args['note_id'] = match.group(1)
return args
@@ -2754,6 +3050,9 @@ def normalize_preview_function_args(name, args, *, user_text=''):
args.pop('filter', None)
if canonical(name) == 'manage_notes':
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
if action == 'read' and (args.get('id') or args.get('note_id')):
args['action'] = 'view'
action = 'view'
if action == 'list':
request = str(user_text or '').casefold()
# The notes backend interprets title/query/content on ``list`` as
@@ -2770,6 +3069,10 @@ def normalize_preview_function_args(name, args, *, user_text=''):
args.pop('archived', None)
if not re.search(r'\b(?:checklists?|freeform\s+notes?|notes?\s+only)\b', request):
args.pop('note_type', None)
query, _ = note_read_intent(user_text)
if query:
args['action'] = 'search'
args['query'] = query
if canonical(name) == 'manage_skills':
action = str(args.get('action') or '').strip().replace('-', '_').casefold()
if action in {'update', 'change', 'revise'}:
@@ -2897,8 +3200,9 @@ def scope_preview_contract(preview_contract, routed_contract, active_capabilitie
requested_family_tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in active))
available_requested = routed_canonical & offered_canonical & requested_family_tools
routed_canonical.update(canonical(name) for name in extra_tools)
if 'image_generation' not in active:
# A past image request must not keep generation warm on email/doc turns.
if 'image_generation' not in active and 'photo_editor' not in active:
# A past image request must not keep generation warm on email/doc turns,
# unless the photo_editor family (which bundles generate_image) is active.
routed_canonical.discard('generate_image')
missing_active = {
f'capability:{family}' for family in active
@@ -3230,7 +3534,11 @@ def _successful_email_identifiers(history):
for message in history or ():
if not isinstance(message, dict):
continue
if message.get('role') == 'system' and 'Message UID:' in str(message.get('content') or ''):
active_reader = (
message.get('_agent_injected') == 'context'
and (message.get('metadata') or {}).get('source') == 'active email reader'
)
if (message.get('role') == 'system' or active_reader) and 'Message UID:' in str(message.get('content') or ''):
extracted = _email_identifiers_from_text(message.get('content'))
known['uid'].update(extracted['uid'])
known['message_id'].update(extracted['message_id'])
@@ -3518,6 +3826,20 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
runtime_tools = PREVIEW_TOOLS | (
NATIVE_WORKSPACE_TOOLS if allow_native_workspace else frozenset()
) | ({canonical(tool) for tool in external_runtime_tools} if allow_native_workspace else set())
# Spec turns are driven by the router: the Spec contract (its offered set)
# IS the model runtime, so a Spec pick is runnable. Keyed off the same
# marker the spec hook sets (`routing_experiment == 'spec-router'`), never
# a new flag, and never applied to compact/native turns.
try:
from src.turn_contract import active_turn_contract as _atc
_spec_contract = _atc()
if _spec_contract is not None and getattr(
_spec_contract, 'routing_experiment', '') == 'spec-router':
runtime_tools = runtime_tools | {
canonical(tool) for tool in getattr(_spec_contract, 'offered', ()) or ()
}
except Exception:
pass
if bare not in runtime_tools:
return decision(False, 'tool_not_in_model_runtime')
if bare in {canonical(tool) for tool in external_runtime_tools}:
@@ -3559,6 +3881,9 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
or bare in BROKERED_JOB_TOOLS
or (contract_required and bare in {'edit_image', 'generate_image'})
or (contract_required and bare == 'create_document' and standalone_code_request(user_text))
# The editor command is conservatively classified as a write;
# document.info is a read even when the user requested no edit.
or (bare == 'run_editor_command' and args.get('op') == 'document.info')
)
)
if family not in authorized and not contextual_revision and not contract_scoped_mutation and not fixture_delete and not offered_private_action:
@@ -3619,6 +3944,18 @@ def evaluate_preview_call(name, args, user_text='', *, allow_execute_code=False,
# editor is offered only for an explicit image-editing turn.
blocked_effects.remove(ToolEffect.NETWORK_EGRESS)
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
if (
(contract_required or family in turn_authorized_families)
and bare in {'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image'}
):
# Scoped photo_editor agent tools. They are never in the general tool
# list; the immutable turn contract only selects them for an explicit
# editor request (Quick Edit popup / image-edit turn), so their UI and
# brokered-network effects are covered by the contract seal.
blocked_effects.discard(ToolEffect.UI_SIDE_EFFECT)
allowed_effects.add(ToolEffect.UI_SIDE_EFFECT)
blocked_effects.discard(ToolEffect.NETWORK_EGRESS)
allowed_effects.add(ToolEffect.NETWORK_EGRESS)
if (
contract_required
and bare in {'download_model', 'serve_preset', 'stop_served_model'}
@@ -3849,6 +4186,29 @@ def conversation(history_session, messages, *, owner=None, diagnostics=None):
from src.turn_contract import result_reference_followup
source_context = (recent_source_reference_context(groups[-2])
if len(groups) > 1 and result_reference_followup(current_text) else None)
# Tool output can dwarf the dialogue (especially a web search). Keep the
# visible exchange before enforcing the history cap, so one search-heavy
# turn cannot erase the subject of the next follow-up.
for index, group in enumerate(groups[:-1]):
if len(json.dumps(group)) <= 5000 or len(group) < 2:
continue
# Keep non-web tool evidence intact: a note/list follow-up may need
# exact result identifiers, and shortening it would be misleading.
if not any(
canonical((call.get('function') or {}).get('name', '')) in {'web_search', 'web_fetch'}
for message in group if message.get('role') == 'assistant'
for call in message.get('tool_calls') or ()
):
continue
answer = next((message.get('content') for message in reversed(group)
if message.get('role') == 'assistant'
and str(message.get('content') or '').strip()), None)
if answer is None:
continue
answer_text = str(answer)
if len(answer_text) > 3500:
answer_text = answer_text[:3500] + '\n[Earlier answer shortened for context.]'
groups[index] = [group[0], {'role': 'assistant', 'content': answer_text}]
# Drop whole turns only, never orphan tool results from their native calls.
groups = groups[-8:]
while len(groups) > 1 and len(json.dumps(groups)) > 22000:
@@ -4207,6 +4567,26 @@ def denied_response():
return 'I can’t perform that operation in this preview. No changes were made.'
WORKSPACE_REQUIRED_REFUSAL = (
'Shell and Python commands only run inside a workspace. '
'Open the code workspace (or set a working directory) and try again.'
)
def workspace_process_denial(executions) -> bool:
"""True when a blocked call was refused because process execution needs a
sealed workspace (a plain chat turn carries none)."""
for entry in executions or ():
blob = ' '.join(
str(entry.get(key) or '') for key in ('error', 'output', 'failure_kind')
)
# Resource identity also covers missing note and document selectors.
# Those denials must not turn into instructions to open a code workspace.
if 'sealed workspace' in blob.casefold():
return True
return False
def execution_has_write_effect(tool_name, content, capability, *, native_workspace_enabled):
"""Recognize successful native code that visibly mutates the workspace."""
@@ -4226,7 +4606,7 @@ _MUTATION_REQUEST = re.compile(
r'\b(?:add|creat(?:e|ing)?|make|writ(?:e|ing)|sav(?:e|ing)|updat(?:e|ing)|edit(?:ing)?|chang(?:e|ing)|'
r'set|schedul(?:e|ing)|reschedul(?:e|ing)|paus(?:e|ing)|resum(?:e|ing)|toggle|mark|'
r'pin|archive|delet(?:e|ing)|remov(?:e|ing)|send|reply|draft|publish|run|launch|serve|start|stop|'
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|feedback|reserve|block)\b|'
r'remember|remeber|forget|remind|review|proofread|suggest(?:ions?)?|expand|broaden|deepen|lighten|reserve|block)\b|'
r'\bgo\s+deeper\b',
re.I,
)
@@ -4262,7 +4642,7 @@ def mutation_action_requested(user_text):
actionable = re.sub(
r"\b(?:do\s+not|don't|never|without)\s+"
r"(?:(?:chang(?:e|ing)|modif(?:y|ying)|edit(?:ing)?|delet(?:e|ing)|"
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing))\b)"
r"remov(?:e|ing)|send(?:ing)?|writ(?:e|ing)|creat(?:e|ing)|schedul(?:e|ing))\b)"
r"[^.;\n]{0,120}?(?=(?:[.;\n]|\bbut\b|\binstead\b|$))",
'', text, flags=re.I,
)
@@ -4685,7 +5065,7 @@ def email_search_result_empty(value):
try:
decoded = json.loads(value)
except (TypeError, ValueError):
return bool(re.fullmatch(r'\s*No emails matched [^\n]+\.?\s*', value, re.I))
return bool(re.fullmatch(r'\s*No emails matched [^\n]+(?:\n\s*)?', value, re.I))
if decoded == value:
return False
value = decoded
@@ -4959,6 +5339,16 @@ def private_browser_effective_url(result):
return urls[-1] if urls else ''
def is_google_sorry_url(url):
"""Recognize Google's rate-limit interstitial."""
# Matching the host separately keeps this linear; one combined pattern
# rescans the host from every dot.
return any(
re.fullmatch(r'(?:[^/]+\.)?google\.[^/]+', host[1], re.I)
for host in re.finditer(r'https?://([^/]+)/sorry/', url, re.I)
)
def browser_observation_page_missing(raw):
"""Recognize a rendered error page, rather than an article mentioning 404."""
text = str(raw or '')
@@ -5242,6 +5632,10 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
external_tool_schemas=None, temperature=0.0,
context_resolution=None,
request_authority=MISSING_AUTHORITY, **ignored):
if spec_driver_active(endpoint_url, model, owner, headers):
yield event({'delta': 'Spec driver mode is unavailable because its authority and completion checks are not supported. No action was performed.'})
yield 'data: [DONE]\n\n'
return
from src.generation_sampling import validate_temperature
temperature = validate_temperature(temperature)
# This path sends requests directly with httpx and therefore bypasses
@@ -5622,6 +6016,38 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'multimodal_image_count': image_context_count,
'attachment_reference_count': attachment_refs,
'image_rehydration': conversation_diagnostics.get('image_rehydration')})
_photo_editor_active = 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ())
_photo_editor_open = bool(
((client_runtime_context or {}).get('web_ui_state') or {}).get('photo_editor_open')
)
if _photo_editor_active and not _photo_editor_open:
_editor_guidance = (
"This image isn't open in the editor yet, so I can't apply edits to the "
"canvas from chat. Open the attached image in the editor (Quick Edit) and "
"send this request there \u2014 I'll apply it to the canvas."
)
history.append({'role': 'assistant', 'content': _editor_guidance})
yield event({'delta': _editor_guidance})
_action_prompt = str(direct_user_text or '')
for _annotation in ('\n\n[Image:', '\n[Image:', '\n\n[Attached', '\n[No vision model'):
_cut = _action_prompt.find(_annotation)
if _cut != -1:
_action_prompt = _action_prompt[:_cut]
_action_prompt = _action_prompt.strip() or str(direct_user_text or '').strip()
yield event({
'type': 'final_response',
'content': _editor_guidance,
'photo_editor_action': {
'kind': 'open_and_apply',
'prompt': _action_prompt,
},
})
yield event({'type': 'metrics', 'data': {
'model': model, 'rounds': 0, 'round_texts': [_editor_guidance],
'tool_events': [], 'tool_calls': 0, 'agent_rounds': 0,
}})
yield 'data: [DONE]\n\n'
return
try:
async with httpx.AsyncClient(
timeout=preview_http_timeout(
@@ -5703,6 +6129,40 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
usage_in += intent_accounting.get('input_tokens', 0)
usage_out += intent_accounting.get('output_tokens', 0)
has_real_usage = intent_accounting.get('usage_source') == 'real'
# Optional env-gated router decision (spec surface only). Computed
# once per user turn; disabled/None leaves behaviour unchanged.
_spec_route_think = await spec_router_thinking_decision_async(
endpoint_url, model, owner, headers, direct_user_text)
if _spec_route_think is not None:
logging.getLogger(__name__).info(
'spec think router: think=%s p_think=%s ms=%s',
_spec_route_think.get('think'),
_spec_route_think.get('p_think'),
_spec_route_think.get('ms'))
# Optional env-gated direct-answer override (spec surface only):
# when the spec router chose answer_directly with high confidence,
# round 1 is sent with tool_choice 'none' (same tools list).
_spec_direct_p = spec_direct_no_tools_p(
direct_user_text, endpoint_url, model, owner, headers)
if (turn_contract.required or turn_contract.unavailable
or turn_contract.required_read_operation is not None):
_spec_direct_p = None
# Option B: send round 1 with no tools at all for a direct answer.
_spec_direct_empty_menu = (
_spec_direct_p is not None and spec_direct_mode() == 'empty_menu')
# ODYSSEUS_SPEC_MODE=driver: the router (not the 9B) picks the tool
# every round; the 9B only writes the arguments or the answer.
# Driver execution is refused at entry. Keep this false even if
# configuration changes while the stream is already in progress.
_spec_driver_on = False
# Driver mode narrows the contract per round to the Spec pick.
# Rebuild each round from this frozen base so the narrowing does
# not accumulate/mutate across rounds.
_driver_base_contract = turn_contract
# Per-round Spec pick (driver mode): 'answer_directly' | 'ask_user'
# | 'tool' | None (router fallback). Set inside the round loop.
_driver_round_kind = None
_driver_round_pick = None
# One extra iteration is available only when a provider emits raw
# tool markup during the normal final no-tools round. Ordinary
# turns still obey ``round_limit`` exactly.
@@ -5839,7 +6299,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
'completion_reserve_seconds': artifact_completion_reserve_seconds,
})
request_messages = provider_request_messages(
prune_multimodal_images(history, max_images=3)
prune_multimodal_images(
keep_recent_image_turns(history, keep=2), max_images=3
)
)
if prior_summary_answer or force_no_tools_next_round or reserve_final_synthesis:
round_offered = []
@@ -6036,6 +6498,94 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
break
request['tools'] = source_tools
request['tool_choice'] = 'required'
# ODYSSEUS_SPEC_MODE=driver: the router is the tool driver.
# Ask it for this round's single next action (any confidence);
# the 9B then only writes the arguments (a named tool_choice) or
# the answer (tools key omitted; vLLM rejects an empty array).
# Router error/timeout keeps the normal menu for this round.
if _spec_driver_on and round_offered:
# The router chooses from the FULL Ajax Spec surface (~all
# built-ins minus disabled), not the compact round menu, so
# a Spec pick is never silently out of reach. The 9B then
# only writes the arguments (a named tool_choice) or the
# answer (tools key omitted; vLLM rejects an empty array).
# Router error/timeout keeps the normal menu this round.
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS as _SPEC_SURFACE
_drv = spec_driver_pick(history, disabled_tools, direct_user_text)
if _drv is not None:
_drv_choice = _drv.get('choice')
if _drv_choice == 'answer_directly':
_driver_round_kind = 'answer_directly'
request.pop('tools', None)
request.pop('tool_choice', None)
elif _drv_choice == 'ask_user':
_driver_round_kind = 'ask_user'
_ask = [s for s in _SPEC_SURFACE
if (s.get('function') or {}).get('name') == 'ask_user']
if _ask:
request['tools'] = [_ask[0]]
request['tool_choice'] = {
'type': 'function',
'function': {'name': 'ask_user'},
}
if _ask[0] not in round_offered:
round_offered = list(round_offered) + [_ask[0]]
elif _drv_choice == 'tool' and _drv.get('tool_name'):
_name = _drv['tool_name']
_sel = [s for s in list(round_offered) + list(_SPEC_SURFACE)
if (s.get('function') or {}).get('name') == _name]
if _sel:
_driver_round_kind = 'tool'
_driver_round_pick = _name
request['tools'] = [_sel[0]]
request['tool_choice'] = {
'type': 'function',
'function': {'name': _name},
}
# The per-call validator looks the schema up in
# `round_offered` (not `request['tools']`), so the
# Spec pick must be present there too.
round_offered = [_sel[0]]
# Driver mode narrows the whole contract to the
# Spec pick for this round: the router is the
# authority, and the executor must be able to run
# the picked tool. Build a valid contract (the
# TurnContract invariant requires
# required <= offered <= executable and
# schema_json names == offered).
try:
from src.turn_contract import (
_ACTIVE_CONTRACT as _TC,
canonical_tool as _ct,
)
_pick = _ct(_name)
turn_contract = replace(
_driver_base_contract,
required=frozenset(),
offered=frozenset({_pick}),
executable=frozenset(
set(_driver_base_contract.executable) | {_pick}),
schema_json=(json.dumps(_sel[0]),),
required_read_operation=None,
)
_TC.set(turn_contract)
except Exception:
turn_contract = _driver_base_contract
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s contract admit failed; '
'normal contract kept', round_number, _name)
else:
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s not executable; normal menu this round',
round_number, _name)
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=%s p=%s', round_number,
(_drv.get('tool_name') if _drv_choice == 'tool'
else _drv_choice),
_drv.get('p'))
else:
logging.getLogger(__name__).info(
'[spec-driver] round=%s pick=fallback p=None', round_number)
pending, content, round_reasoning = {}, '', ''
document_preview_index = None
document_preview_content = ''
@@ -6056,6 +6606,49 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
request = search_tool_choice_request(request)
tool_call_requested = request.get('tool_choice') not in (None, 'auto', 'none')
request = provider_compatible_tool_choice_request(request, model)
# Log the tool surface actually sent to the provider: the
# metrics `tool_schema_names` reports the pre-narrowing
# `offered` set, which hid the fact that a forced choice had
# shrunk the provider tools array to one schema.
logging.getLogger(__name__).info(
"clean preview round %s sending %d tool(s): %s",
round_number,
len(request.get('tools') or []),
[
(schema.get('function') or {}).get('name')
for schema in (request.get('tools') or [])
][:40],
)
if round_number == 1 and _spec_direct_p is not None:
# The router decided to answer directly with high
# confidence.
if _spec_direct_empty_menu:
# Option B: an empty menu — send no tools at all, so
# nothing can be executed and no markup is recovered.
request['tools'] = []
request.pop('tool_choice', None)
tool_call_requested = False
# Keep the thinking router's decision; never let the
# no-tools progressive rule turn thinking on when the
# router said off.
if not (_spec_route_think or {}).get('think'):
ctk = dict(request.get('chat_template_kwargs') or {})
ctk['enable_thinking'] = False
request['chat_template_kwargs'] = ctk
logging.getLogger(__name__).info(
'[spec-harness] direct answer: empty menu p=%s',
_spec_direct_p)
else:
# Default: keep the same tools list (prefix-cache
# friendly) but forbid a tool call this round. Thinking
# is left to the thinking router; offered stays
# non-empty so this does not switch progressive
# thinking on.
request['tool_choice'] = 'none'
tool_call_requested = False
logging.getLogger(__name__).info(
'[spec-harness] direct answer: tool_choice none p=%s',
_spec_direct_p)
if active_editor_target and request.get('tools'):
# Edits share mutable document state. Await a saved result
# before asking for another call; one call may still batch
@@ -6144,6 +6737,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
and not progressive_thinking
and not source_lookup_pending
and not tool_call_requested
and not _spec_direct_empty_menu
):
text_event = {'delta': text}
if replace_streamed_draft_on_finish and not streamed_round_text:
@@ -6197,6 +6791,13 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if content and not prior_summary_answer and not source_lookup_pending:
yield event({'delta': content})
streamed_round_text = True
if _spec_direct_empty_menu and pending:
# Option B: nothing is offered, so never execute a tool
# call the model still produced (native shape).
logging.getLogger(__name__).info(
'[spec-harness] direct answer: dropped %d tool call(s)',
len(pending))
pending = {}
proposed = [pending[i] for i in sorted(pending)]
# A lead-in emitted before a tool call is live progress, not
# part of the terminal answer. Replace that draft when the
@@ -6204,7 +6805,9 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
if proposed and streamed_round_text:
replace_streamed_draft_on_finish = True
unexecutable_dsml_completion = False
if not proposed and 'DSML' in content:
if (not proposed and not _spec_direct_empty_menu
and _driver_round_kind != 'answer_directly'
and 'DSML' in content):
offered_by_canonical = {
canonical(schema['function']['name']): schema['function']['name']
for schema in round_offered
@@ -6252,6 +6855,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
})
else:
unexecutable_dsml_completion = True
if _spec_direct_empty_menu and content:
# Option B: show the answer text without any tool-call
# markup the model may still have written (nothing offered).
_stripped = re.sub(
r'<tool_call\b[^>]*>.*?</tool_call>', '',
content, flags=re.DOTALL | re.IGNORECASE).strip()
if _stripped != content.strip():
content = _stripped
replace_streamed_draft_on_finish = True
if source_lookup_pending and not proposed:
if source_answer_retries < 1 and round_number < round_limit:
source_answer_retries += 1
@@ -6276,6 +6888,37 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
)
if active_editor_target:
proposed = drop_redundant_editor_noops(proposed)
# ODYSSEUS_SPEC_MODE=driver: Spec is the tool driver, so execute
# ONLY the call Spec picked this round. Drop (never execute) any
# other native call and any text/DSML-recovered call; when Spec
# said answer_directly, keep no calls and strip tool markup so
# the remaining text is the answer.
if _driver_round_kind == 'answer_directly':
if proposed:
for _c in proposed:
logging.getLogger(__name__).info(
'[spec-driver] dropped off-pick call name=%s',
(_c.get('function') or {}).get('name'))
proposed = []
if content:
_stripped = strip_tool_blocks(content, skip_fenced=True).strip()
_stripped = re.sub(
r'<tool_call\b[^>]*>.*?</tool_call>', '',
_stripped, flags=re.DOTALL | re.IGNORECASE).strip()
if _stripped != content:
content = _stripped
replace_streamed_draft_on_finish = True
elif _driver_round_kind in ('tool', 'ask_user'):
_want = 'ask_user' if _driver_round_kind == 'ask_user' else _driver_round_pick
_keep = []
for _c in proposed:
_cn = (_c.get('function') or {}).get('name')
if canonical(str(_cn or '')) == canonical(str(_want or '')):
_keep.append(_c)
else:
logging.getLogger(__name__).info(
'[spec-driver] dropped off-pick call name=%s', _cn)
proposed = _keep
if model_choice_experiment:
for proposal in proposed:
yield event({'type': 'model_tool_proposal', 'round': round_number,
@@ -6695,6 +7338,25 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
# that draft and persisted history with the evidence-backed result.
history.pop()
refusal = denied_response()
# A photo-editor turn with no canvas command actually executed
# (no browser editor attached) cannot claim the edit. Point the
# user at the editor instead of the generic no-op refusal.
if 'photo_editor' in (getattr(turn_contract, 'active_capabilities', ()) or ()):
touched_canvas = any(
str(e.get('tool') or '') in {
'run_editor_command', 'get_editor_screenshot',
'ai_edit_image', 'generate_image',
}
and not e.get('error') and e.get('exit_code', 0) == 0
for e in executions
)
if not touched_canvas:
refusal = (
'This photo is not open on the editor canvas, so I cannot '
'apply edits to it from chat. Open the attached image in the '
'editor (Quick Edit) and send the request there, and I will '
'apply it to the canvas.'
)
history.append({'role': 'assistant', 'content': refusal})
yield event({'type': 'final_response', 'content': refusal})
break
@@ -7561,11 +8223,7 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
browser_search_url = requested_browser_url or effective_browser_url
is_search_engine_navigation = contains_search_engine_navigation(
browser_search_url
) or bool(re.search(
r'https?://(?:[^/]+\.)?google\.[^/]+/sorry/',
effective_browser_url,
re.I,
))
) or is_google_sorry_url(effective_browser_url)
has_native_search = any(
canonical(schema['function']['name']) == 'web_search'
for schema in offered
@@ -7948,6 +8606,15 @@ async def stream_preview(*, endpoint_url, model, messages, headers, turn_contrac
output, user_text=latest_user,
max_items=contract_item_limit(turn_contract, 20),
)
followup = note_locator_followup(output, user_text=latest_user)
if followup:
if round_number < round_limit:
structured_terminal_response = ''
round_recovery_messages.append(followup)
else:
structured_terminal_response = (
'I found a note, but could not read its content in this turn.'
)
action = str(args.get('action') or '').replace('-', '_').casefold()
if (
action in {'search', 'find'}
+1
View File
@@ -175,6 +175,7 @@ AGENT_ISOLATED_TMP_DIRNAME = ".tmp"
# Auth policy
PASSWORD_MIN_LENGTH = 8
USERNAME_MAX_LENGTH = 64
# Default parameters
DEFAULT_TEMPERATURE = 1.0
+168 -14
View File
@@ -42,6 +42,15 @@ def _content_as_text(content: Any) -> str:
_MULTIMODAL_IMAGE_TYPES = {"image_url", "input_image", "image"}
def _has_preservable_content(content: Any) -> bool:
"""Retain nonblank text or nonempty multimodal blocks after tool-call removal."""
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return bool(content)
return False
def prune_multimodal_images(
messages: List[Dict],
*,
@@ -92,6 +101,84 @@ def prune_multimodal_images(
return pruned
_IMAGE_TURN_LABEL_RE = re.compile(r"\[Image(?:s)?(?:[ \t]{1,64}attached)?:[ \t]{0,64}([^\]]{1,512})\]", re.IGNORECASE)
def _image_turn_label(message: Dict) -> str:
"""Best-effort '[Image: name]' label for a message whose image parts are dropped."""
name = ""
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES:
image_url = item.get("image_url")
candidate = item.get("name") or (image_url.get("name") if isinstance(image_url, dict) else None)
if candidate:
name = str(candidate)
break
if not name:
text = _content_as_text(content)
match = _IMAGE_TURN_LABEL_RE.search(text)
if match:
name = match.group(1).strip()
return f"[Image: {name}]" if name else "[Image]"
def keep_recent_image_turns(messages: List[Dict], *, keep: int = 2) -> List[Dict]:
"""Keep image parts only for the last `keep` messages that carry an image.
Older image blocks are dropped and replaced with a short ``[Image: name]``
text label so the model still knows a picture was shared (and its name)
without paying the image tokens on every later turn. Non-text, non-image
multimodal blocks (e.g. audio) are left untouched.
"""
limit = max(0, int(keep))
image_messages = [
index for index, message in enumerate(messages)
if isinstance(message, dict)
and isinstance(message.get("content"), list)
and any(
isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES
for item in message["content"]
)
]
if len(image_messages) <= limit:
return list(messages)
selected = set(image_messages[len(image_messages) - limit:]) if limit else set()
trimmed: List[Dict] = []
for message_index, message in enumerate(messages):
if message_index in selected or not isinstance(message, dict):
trimmed.append(message)
continue
content = message.get("content")
if not isinstance(content, list):
trimmed.append(message)
continue
kept_blocks = [
dict(item) if isinstance(item, dict) else item for item in content
if not (isinstance(item, dict) and item.get("type") in _MULTIMODAL_IMAGE_TYPES)
]
if len(kept_blocks) == len(content):
trimmed.append(message)
continue
label = _image_turn_label(message)
# Attach the label to the trailing text block when possible so we do not
# introduce an extra content block; otherwise append a fresh text block.
labelled = False
for item in reversed(kept_blocks):
if isinstance(item, dict) and item.get("type") == "text":
item["text"] = (item.get("text") or "").rstrip() + f"\n{label}"
labelled = True
break
if not labelled:
kept_blocks.append({"type": "text", "text": label})
cloned = dict(message)
cloned["content"] = kept_blocks
trimmed.append(cloned)
return trimmed
COMPACT_THRESHOLD = 0.85 # Trigger compaction at 85% of context window
SUMMARY_MAX_TOKENS = 1024
SMALL_CONTEXT_LIMIT = 8192 # Models with context <= this get aggressive trimming
@@ -106,7 +193,7 @@ def auto_compact_threshold_percent() -> int:
return max(50, min(95, value))
# Cursor-style self-summarization prompt — produces structured, dense summaries
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation to preserve context after compaction. Produce a structured summary that lets the conversation continue seamlessly.
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation with Ajax, the user's AI assistant, to preserve context after compaction. Produce a structured summary that lets Ajax continue seamlessly. Ajax is the assistant's name; Odysseus is the app name.
Use this format:
@@ -144,22 +231,81 @@ def normalize_compaction_summary(summary: str) -> str:
def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
"""Drop orphaned `tool` messages and dangling assistant `tool_calls`.
"""Normalize tool-call message ordering so strict providers accept the transcript.
OpenAI's API requires every `role:"tool"` message to immediately
follow an assistant message that carries `tool_calls` (or another
tool message in the same batch). Front-trimming the history can cut
the assistant `tool_calls` parent while keeping its tool responses,
which triggers: "messages with role 'tool' must be a response to a
preceding message with 'tool_calls'". This pass repairs that:
- drops `tool` messages with no valid preceding tool_calls
- drops assistant `tool_calls` messages whose tool responses were
all trimmed away (some providers reject unanswered tool_calls)
tool message in the same batch). Gemini and some other providers
additionally require a function call turn to come immediately after
a user turn or a function response turn. Trimming the history can
break either rule, which triggers errors such as "messages with role
'tool' must be a response to a preceding message with 'tool_calls'"
and "Please ensure that function call turn comes immediately after
a user turn or after a function response turn."
The transcript is normalized against its own effective output: each
pass repairs the violations it can see and the passes repeat until
the result is stable, so the returned transcript itself satisfies
the ordering contract and sanitizing it again is a no-op. A
completed call/result batch whose predecessor is an adjacent
assistant text turn keeps its calls by merging that text into the
call message instead of dropping executed-tool evidence; a batch
that cannot get a user/tool predecessor (e.g. a transcript that
starts mid-history) is the only one dropped.
"""
# Pass 1: drop orphan tool messages.
work = list(msgs)
for _ in range(2 * len(work) + 2):
normalized = _normalize_tool_messages_once(work)
if normalized == work:
break
work = normalized
return work
def _normalize_tool_messages_once(msgs: List[Dict]) -> List[Dict]:
"""One normalization pass over an already-trimmed message list."""
# Merge the run of assistant text turns directly before an assistant
# tool_calls turn into a single assistant message carrying both, so the
# batch inherits the text run's predecessor (a user or tool message)
# instead of losing its tool_calls. Only plain-text content is merged;
# multimodal content stays untouched and falls through to the strip logic.
merged: List[Dict] = []
for m in msgs:
cur_text = m.get("content")
if (
m.get("role") == "assistant"
and m.get("tool_calls")
and (cur_text is None or isinstance(cur_text, str))
):
text_run: List[Dict] = []
while (
merged
and merged[-1].get("role") == "assistant"
and not merged[-1].get("tool_calls")
and isinstance(merged[-1].get("content"), str)
):
text_run.append(merged.pop())
if text_run:
text_parts = [
msg["content"].strip()
for msg in reversed(text_run)
if msg["content"].strip()
]
if isinstance(cur_text, str) and cur_text.strip():
text_parts.append(cur_text.strip())
merged_msg = dict(m)
if text_parts:
merged_msg["content"] = "\n\n".join(text_parts)
merged.append(merged_msg)
continue
merged.append(m)
# Strip tool_calls from assistant messages whose predecessor is not a
# user or tool message, then drop orphan tool messages and dangling
# tool_calls whose responses are gone.
cleaned: List[Dict] = []
in_batch = False # are we right after an assistant tool_calls (or mid-batch)?
for m in msgs:
for m in merged:
role = m.get("role")
if role == "tool":
if in_batch:
@@ -167,13 +313,21 @@ def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
# else: orphan — drop
continue
if role == "assistant" and m.get("tool_calls"):
prev_role = cleaned[-1].get("role") if cleaned else None
if prev_role not in ("user", "tool"):
# Invalid predecessor — strip tool_calls to avoid provider
# errors; keep the message only if it still carries content.
m = {k: v for k, v in m.items() if k != "tool_calls"}
in_batch = False
if not _has_preservable_content(m.get("content")):
continue # nothing left worth keeping
cleaned.append(m)
continue
in_batch = True
else:
in_batch = False
cleaned.append(m)
# Pass 2: drop assistant tool_calls messages that have NO following
# tool response (dangling) — walk backwards so we know what follows.
out: List[Dict] = []
for i, m in enumerate(cleaned):
if m.get("role") == "assistant" and m.get("tool_calls"):
@@ -183,7 +337,7 @@ def _sanitize_tool_messages(msgs: List[Dict]) -> List[Dict]:
# tool_calls so it's a plain assistant turn (preserves any
# text content the model produced alongside the calls).
m = {k: v for k, v in m.items() if k != "tool_calls"}
if not (m.get("content") or "").strip():
if not _has_preservable_content(m.get("content")):
continue # nothing left worth keeping
out.append(m)
return out
+29 -49
View File
@@ -31,63 +31,43 @@ def _internal_headers() -> dict:
async def _delete_endpoint_for_task(task: dict) -> None:
"""Drop the auto-registered model endpoint for a scheduled-stop serve.
"""Remove a stopped serve's endpoint using the normal deletion contract."""
import re
from urllib.parse import quote
Without this, killing the tmux session leaves the endpoint sitting in
the picker (probe goes offline; chats still try to route there) and
the user has to delete it by hand in Settings -> Endpoints.
"""
endpoint_id = (task.get("_endpointId") or task.get("endpointId") or "").strip()
if not endpoint_id:
logger.info(
"cookbook_serve_lifecycle: task %s has no endpoint id; skipping endpoint deletion",
task.get("sessionId") or task.get("id") or "",
)
endpoint_id = str(task.get("_endpointId") or task.get("endpointId") or "").strip()
serve_session_id = str(task.get("sessionId") or task.get("id") or "").strip()
if (
not re.fullmatch(r"(?:local|img)-[0-9a-f]{8}", endpoint_id)
or not serve_session_id
):
logger.info("cookbook_serve_lifecycle: missing endpoint/session identity")
return
import re as _re
payload = task.get("payload") or {}
cmd = str(payload.get("_cmd") or "")
remote = task.get("remoteHost") or ""
# Build host the same way _auto_register_llm_endpoint does so URL match wins.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "host.docker.internal"
port_match = _re.search(r"--port\s+(\d+)", cmd)
ollama_host_match = _re.search(r"OLLAMA_HOST=[^\s]*?:(\d+)", cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in cmd:
port = 11434
else:
port = 8080
base_url = f"http://{host}:{port}/v1"
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
f"{internal_api_base()}/api/model-endpoints",
headers=_internal_headers(),
)
if r.status_code >= 400:
return
eps = r.json() if r.content else []
# Delete only the endpoint created by this scheduled serve. URL
# matching is unsafe because a later scheduled serve can reuse the
# same host:port after an older task has gone stale.
ep = next((e for e in eps if e.get("id") == endpoint_id), None)
if ep:
await client.delete(
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
response = await client.delete(
f"{internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
params={"expected_cookbook_serve_session_id": serve_session_id},
headers=_internal_headers(),
)
if response.status_code == 200:
logger.info(
f"cookbook_serve_lifecycle: deleted endpoint {ep.get('id')} "
f"({ep.get('base_url')}) after scheduled stop"
"cookbook_serve_lifecycle: deleted endpoint %s for serve %s",
endpoint_id, serve_session_id,
)
except Exception as e:
logger.warning(f"cookbook_serve_lifecycle: endpoint delete failed: {e}")
elif response.status_code in (404, 409):
logger.info(
"cookbook_serve_lifecycle: endpoint %s absent or reassigned; keeping it",
endpoint_id,
)
else:
logger.warning(
"cookbook_serve_lifecycle: endpoint %s deletion returned HTTP %s",
endpoint_id, response.status_code,
)
except Exception as exc:
logger.warning("cookbook_serve_lifecycle: cleanup failed: %s", exc)
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
+4 -2
View File
@@ -625,7 +625,8 @@ class DeepResearcher:
# LLM helper
# ------------------------------------------------------------------
async def _llm(self, messages: List[Dict], temperature: float = 0.3,
max_tokens: int = 4096, timeout: int = 60) -> str:
max_tokens: int = 4096, timeout: int = 60,
thinking_mode: Optional[str] = None) -> str:
"""Call the LLM asynchronously and strip thinking tags."""
from src.llm_core import llm_call_async
response = await llm_call_async(
@@ -636,6 +637,7 @@ class DeepResearcher:
max_tokens=max_tokens,
headers=self.llm_headers,
timeout=timeout,
thinking_mode=thinking_mode,
)
return strip_thinking(response)
@@ -696,7 +698,7 @@ class DeepResearcher:
try:
result = await self._llm(
[{"role": "user", "content": prompt}],
temperature=0, max_tokens=20, timeout=15,
temperature=0, max_tokens=20, timeout=15, thinking_mode="off",
)
cat = (result or "").strip().lower()
# Clean one-word answer first.
+51 -5
View File
@@ -2,6 +2,7 @@
"""Document processing: PDF/OCR extraction, text file handling, image VL analysis, user content building."""
import os
import io
import logging
import mimetypes
import base64
@@ -141,7 +142,10 @@ def _process_pdf(
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
temp_img_path = tmp.name
try:
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
image = img.image
if image.mode == "CMYK":
image = image.convert("RGB")
image.save(temp_img_path, "PNG")
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
if ocr_text and "unavailable" not in ocr_text.lower():
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
@@ -496,6 +500,49 @@ def analyze_image_with_vl(image_path: str, owner: str | None = None) -> str:
return analyze_image_with_vl_result(image_path, owner=owner).get("text", "")
def _vision_max_side() -> int:
"""Longest-side cap for images inlined into the chat request (tokens)."""
try:
value = int(os.getenv("ODYSSEUS_VISION_MAX_SIDE", "1280") or 1280)
except (TypeError, ValueError):
value = 1280
return max(0, value)
def encode_image_data_url(path: str, fallback_format: str) -> tuple[str, str]:
"""Return (data_url, image_format) for an image, downscaled if large.
Downscale the longest side to ODYSSEUS_VISION_MAX_SIDE (default 1280) and
re-encode as JPEG so the chat model does not pay full-resolution image
tokens. Small/undecodable images fall back to the original bytes so a
real attachment is never dropped.
"""
max_side = _vision_max_side()
try:
from PIL import Image # local import: keep module import cost low
with Image.open(path) as img:
img = img.convert("RGB") if img.mode not in ("RGB", "L") else img
width, height = img.size
if max_side and max(width, height) > max_side:
scale = max_side / float(max(width, height))
img = img.resize(
(max(1, round(width * scale)), max(1, round(height * scale))),
Image.LANCZOS,
)
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=85, optimize=True)
return (
"data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("utf-8"),
"jpeg",
)
except Exception as exc:
logger.debug(f"Image downscale failed for {path}: {exc}; sending original bytes")
with open(path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
image_format = (fallback_format or "png").lower()
return f"data:image/{image_format};base64,{encoded_string}", image_format
def build_user_content(
text: str,
attachment_ids: list[str] | None,
@@ -542,15 +589,14 @@ def build_user_content(
if upload_handler.is_image_file(display_name, mime):
try:
with open(path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
# Extensionless uploads (e.g. a pasted screenshot) have no ext,
# so fall back to the resolved MIME subtype rather than emitting
# an invalid "data:image/;base64," with an empty subtype.
image_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
fallback_format = ext[1:] or (mime.split("/", 1)[1] if mime.startswith("image/") else "png")
data_url, image_format = encode_image_data_url(path, fallback_format)
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/{image_format};base64,{encoded_string}"},
"image_url": {"url": data_url},
})
except Exception as e:
logger.error(f"Failed to encode image {fid}: {e}")
+732
View File
@@ -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)
+19
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More