Commit Graph

13 Commits

Author SHA1 Message Date
Patrick Buckley c8f0c0cf90 chore(session): house-style polish on shared-workstream feature
- q-2: document the deliberate username-first display-name precedence in
  _resolve_display_name (diverges from auth.py's display_name-first
  because sender labels must match the owner-banner identity kind).
- q-3: drop change-lineage comments referencing the separate acting-user
  credential fix (tombstone noise once merged).
- q-4: tighten the plain-text attachment assertion from a tolerant
  subset check to exact shape + _sender value now that the stamp is
  deterministic.
- q-5: drop the contributor-local bare 'etc/' from .gitignore.
2026-07-02 19:23:04 -07:00
metaclassing 212d1922e5 Multi-user chat context clarification and tool improvements (#750)
* multiuser chat fixes for identity clarity and obo oauth token selection during tool calls

* added some missing context to the session so that the llm would know what session/project to reference in tool calls

* updated to address copilots issues and excluded a local config folder

* I think this resolves the cicd failures

---------

Co-authored-by: pow3rtool <root@pow3rtools>
2026-07-02 16:12:47 -07:00
Patrick Buckley 7da07e2350 perf(attachments): per-send wire-part memo to stop re-rasterizing every round-trip
_resolve_attachments re-runs on every agentic round-trip (and per fallback model), each time re-fetching every attachment across the full history and re-rasterizing / re-base64'ing it. A 10-page PDF in a 10-cycle tool turn was rendered dozens of times.

Add a per-send memo (self._wire_part_cache) keyed by (attachment_id, caps-signature): the materialized wire part is computed at most once per send. The cache is None outside a send (display/export paths unaffected) and reset per send to bound the heavy rasterized-page parts and pick up any mid-session capability change. Skip the DB fetch entirely when every id is already cached.

Also peek the perception (alias, content_hash) memo before building parts in _perception_fallback_part, so a cross-send describe hit no longer wastes a PDF rasterize. Leaves pdf.py's deliberate no-module-cache stance intact — the per-send scope addresses the round-trip amplification without the durable store it defers.

Adds describe_peek() + per-send-cache and peek tests.
2026-06-16 00:48:14 -07:00
Patrick Buckley 558ddadc79 feat(attachments): universal perception fallback for non-native modalities
Add a `perception.model_alias` model role: when the primary model can't ingest
an attachment natively and can't be shown a degraded-but-native form, a
configured perception model perceives it and its output is carried as text.
Mirrors the STT role — a role alias plus a module-level memo so the extra LLM
round-trip runs once per attachment, not once per conversation turn. The call
goes through the provider abstraction's create_completion (the path the intent
judge uses), so any vision/omni provider works.

Bottom-tier, universal ladder — perception only fills the remaining gap:
- pdf  : native supports_pdf -> rasterize-to-vision-primary -> perception
         -> extracted text -> placeholder
- image: native vision -> perception (non-vision primary) -> native image_url
- audio: native supports_audio_input -> STT -> perception (omni) -> placeholder

Folds in two review findings the role subsumes:
- bug-1: thread the active attempt's capabilities into _resolve_attachments
  (bound in _try_stream) so a model fallback materializes attachments against
  the fallback model's caps, not the primary's.
- bug-2: charge a by-reference pdf/audio a bounded budget min(size_bytes, 16K)
  instead of zero, so a large-attachment turn isn't budgeted as ~empty (the
  exact materialized size isn't known until wire build).
2026-06-16 00:48:14 -07:00
Patrick Buckley 471d94b27f feat(attachments): rasterize PDF to page images for vision models without native PDF
A vision-capable model that can't ingest PDF natively now gets the PDF
rendered to one image per page instead of extracted text; falls back to
text extraction when rendering yields nothing.

- core/pdf.py: rasterize_pdf via pypdfium2 render + Pillow PNG (page-capped
  at 10, never raises)
- session._wire_content_part: pdf + !supports_pdf + supports_vision ->
  rasterized image parts; else text extraction
- trajectory.resolve_attachment_parts: a placeholder can now expand to a
  list of parts (1->N); the resolve_attachments callback return type widened
  to dict[str, Any] across the provider protocol + 4 providers
- pyproject: pillow dependency
- tests: rasterize_pdf, vision-rasterize gate path, 1->N materialization
2026-06-16 00:48:14 -07:00
Patrick Buckley addb8d0be8 feat(attachments): capability-gated client-side fallback (pdf->text, audio->transcript)
When the active model can't ingest a kind natively, the wire resolver
converts it client-side instead of sending a part the model can't read.
Per-kind ownership, no shared machinery: PDF text-extraction is a
pure-local PDF concern; audio transcription is an STT concern memoized
in the audio domain.

- core/pdf.py: extract_pdf_text via pypdfium2 (pure-local, no network, no
  cache — re-run per build; page-capped)
- core/audio.py: transcribe_cached — non-raising, memoized by
  (alias, content-hash); backend failures not cached
- session._wire_content_part: per-kind dispatch — native where the model
  supports the kind (supports_pdf / supports_audio_input), else fallback;
  display/export resolve natively so no conversion fires on a render
- image left ungated (pre-existing behavior unchanged)
- pyproject: pypdfium2 dependency + mypy untyped-import override
- tests: pdf extraction, transcript memoization, per-kind gate dispatch
2026-06-16 00:48:14 -07:00
Patrick Buckley 0e0d0bbf72 fix: pre-push review fixes from the canonical-trajectory deep-dive
A deep-dive review of the branch surfaced a budget regression, SDK doc
drift, dead code, and stale docstrings. Each was boundary-spiked before
fixing.

- R1 (regression): by-reference document attachments were invisible to the
  token budget — _msg_text_chars returned 0 doc_chars for a
  {type:document,attachment_id} placeholder, and the comment's claim that
  the budget "lands at calibration" was false (calibration discards
  doc_chars). Thread the doc size through _attachments_meta (size_bytes, at
  both the live-append and reconstruct build sites) and count it in
  _msg_text_chars, guarded against double-counting the inline form.
  Regression test added.
- F1: the history-DTO schema description and the TS HistoryEvent docstring
  still advertised the removed reminders/advisories keys and omitted the
  system role; corrected server_schemas.py + sdk/typescript/src/events.ts to
  match the shipped shape. The committed OpenAPI JSON snapshots were already
  ~679 lines stale on main; their regen is left to its own chore branch.
- D1: removed AttachmentBuffer.take() — dead (no production caller; the
  commit path uses discard()) and scope-weak (ws_id only, unlike its
  siblings) — with its test and the now-orphaned Iterable import.
- O1: 4 docstrings referenced the moved ChatSession._fold_system_turns →
  lowering.fold_system_turns.
2026-06-04 11:03:13 -07:00
Patrick Buckley 964a390e5e feat(attachments): resolve AttachmentRef at the translator; drop RawContentBlock
The by-reference content lane now materializes at the provider translator (the
C layer), not in the session.  Each create_streaming / create_completion takes
a resolve_attachments callback and runs materialize_attachments() up front,
expanding {type:kind, attachment_id} placeholders to inline data-URI / document
parts by a content-addressed point-lookup the session hands down
(_resolve_attachments).  _full_messages emits placeholders; the dict bridge
carries only placeholders.

RawContentBlock is removed — ContentBlock = TextBlock | AttachmentRef.  A
resolved inline part is terminal (the wire payload / display output) and never
re-enters the canonical path, so turn_from_dict drops a stray inline image_url
rather than carrying bytes.  resolve_attachment_parts / materialize_attachments
operate on the dict projection.  Tool vision output rides by reference too
(_tool_content_by_reference): the turn carries placeholders, the bytes persist
content-addressed.  The per-turn token estimate counts a by-ref image as one
fixed image budget; the document char budget lands at send (on resolution).

Wire harness byte-identical (the multipart fixture is a placeholder + a matching
resolver); full non-live suite green (7136).
2026-06-04 11:03:13 -07:00
Patrick Buckley 8c538148cc feat(attachments): AttachmentRef as the canonical by-reference content
Non-text content (user uploads, reloaded tool images) rides as AttachmentRef(id,kind)
in the canonical Turn — session.messages carries ids, never bytes.  Each output
materializes it to inline data-URI/document parts by point-lookup on the content-
addressed store: the wire (ChatSession._lower_messages_to_wire, in _full_messages),
/history + export (reconstruct_messages resolves), and the per-turn token estimate
(a by-ref image costs one fixed image budget; the doc char budget lands at send).
reconstruct splits: reconstruct_turns = unresolved row→Turn (load_message_turns, the
resume path); reconstruct_messages = resolved dict facade.  RawContentBlock is demoted
to the transient carrier for a resolved inline part on the dict↔Turn bridge.
2026-06-04 11:03:13 -07:00
Patrick Buckley dc88060b79 refactor(core): session.messages is the canonical Turn trajectory
ChatSession.messages flips from list[dict] to list[Turn] — the in-memory
canonical trajectory.  Reads migrate to typed fields (turn.role, turn.text,
turn.tool_calls); appends and assignments go through turn_from_dict /
turns_from_dicts; the fork bulk-save and retry's multipart check read via
turn_to_dict.  _full_messages lowers Turns→dicts at the wire boundary — the
fold/repair and provider translators still consume dicts until the next slice.
The token-accounting helpers accept a dict or a Turn.

Non-session consumers migrate too: coordinator_idle_observer and eval to typed
fields (mypy-enumerated), and server's last-assistant extractor via turn_to_dict
(an Any-typed call site mypy could not flag).  An all-text multipart content
list (the unreadable-attachment placeholder path) now round-trips faithfully
through the adapter (single text block → str, multiple → list).

Tests that inspected session.messages as dicts read it through the
dicts_from_turns / turn_to_dict bridge; those that built it pass dicts through
turns_from_dicts / turn_from_dict.  Byte-identical wire harness; full non-live
suite green (7130).
2026-06-04 11:03:13 -07:00
Patrick Buckley 98cee4d20c feat(storage): content-addressed refcounted attachments + in-memory upload buffer
Replace the persisted pending/reserved/consumed upload lifecycle (and its orphan-sweep
and per-user cap) with a content-addressed, refcounted blob store fronted by the per-node
in-memory pending buffer:

- Upload stages bytes in the buffer (keyed by sha256); send-commit drains the referenced
  handles, writes each blob content-addressed (INSERT-OR-IGNORE then refcount += 1, so a
  stored blob is born referenced and dedupes across messages/workstreams), and records the
  ordered conversations.attachments ref-list — the sole message->blob link.
- reconstruct rebuilds inline image_url/document multipart content from the ref-list,
  role-agnostically (so tool-produced images via _exec_read_image now persist + rehydrate
  instead of being flattened to text and lost). Output shape unchanged.
- GC is reference counting: delete_messages_after / delete_workstream decrement once per
  reference and prune a blob at 0; a deduped blob shared with a kept turn (or another ws)
  survives.
- get_content for a committed blob is gated by reference-ownership (the requester owns a
  turn in the ws whose ref-list names the id), replacing the dropped ws_id/user_id scope.
- Migration 060 re-keys legacy consumed attachments to their content hash, dedups, sets
  refcounts, writes the ref-lists, and drops message_id/reserved_*; pending legacy rows are
  dropped (pending now lives only in the buffer).

Both backends symmetric; the reservation methods, cap, and orphan-sweep are removed across
storage/facade/protocol/endpoints/coordinator. Wire harness byte-identical; full suite green.
2026-06-04 11:03:13 -07:00
Patrick Buckley 11f0813329 fix(session): properly inject queued user messages mid-loop (#474)
* fix(session): properly inject queued user messages mid-loop

Two queued-user-message bugs in ``ChatSession.send()``.

**Mid-tool-call: ``Unexpected role 'tool' after role 'user'`` on Mistral.**
The ``supports_tool_advisories`` capability flag (default False for
unknown openai-compatible models) routed cap-off providers down a
short-circuit branch in ``_collect_advisories`` that called
``_flush_queued_messages`` directly. That appended a ``user`` turn
between ``assistant(tool_calls)`` and ``tool``, which mistral-common's
``_validate_message_order`` rejects with a 400.

Drop the flag. All providers now run the unified path: queued user
messages become ``UserInterjection`` advisories that ride inside the
tool result envelope via ``wrap_tool_result``, splicing
``<system-reminder>`` text into the tool message's content. Role
sequence stays ``assistant → tool``. Live-confirmed on Mistral
medium and Qwen3 — both correctly distinguish system-reminder from
tool stdout in their reasoning.

**Mid-stream: queued message orphaned until next user send.**
After a no-tool assistant turn, ``_flush_queued_messages`` would
append the queued user message to history and the loop would
``break``, leaving the message at the tail of history with no
model response. Visible as "two sends to get one reply".

``_flush_queued_messages`` now returns ``bool``. The no-tool branch
``continue``s on drain instead of ``break``ing, so the model gets a
turn over the extended history.

Tests:
- ``test_collect_advisories_drains_text_queued_messages_to_persistent``
  pins the unified-path drain (text-only queue → ``UserInterjection``,
  no separate user turn appended to ``self.messages``).
- ``test_send_continues_when_messages_queued_during_streaming`` pins
  the loop-continue behavior (fails with 1 stream call pre-fix,
  passes with 2 post-fix).

* fix(session,ui): reject queued attachments + paperclip busy state

Copilot pointed out that the attachment-bearing branch in
``_collect_advisories`` had the same role-ordering bug as the
text-only path that 802658f fixed: an attachment-bearing queued
item would still call ``_append_user_turn`` mid-tool-call,
injecting ``user`` between ``assistant(tool_calls)`` and ``tool``.

Pragmatic fix: don't allow attachments to be queued at all.

**Backend.** ``ChatSession.queue_message`` raises a new
``AttachmentsNotQueueableError`` when called with non-empty
``attachment_ids``. The interactive ``/send`` route catches it,
releases reservations via the existing ``_release_reservation_on_fail``
hook, and surfaces ``status: "attachments_busy"`` to the caller
with the IDs in ``dropped_attachment_ids``. The coord adapter
mirrors the cleanup (releases the soft-locked reservation taken
for ``_send_id``) so the create-with-attachments path can't leak.

Now that the queue can never carry attachments, the per-item
``att_ids`` slot is gone:

- Queue tuple slimmed ``(cleaned, priority, att_ids)`` →
  ``(cleaned, priority)``.
- ``_flush_queued_messages`` collapses to a single combined-text
  user turn (no attachment branch).
- ``_collect_advisories`` queue-drain pushes ``UserInterjection``
  advisories only (no ``attachment_items`` list).
- ``dequeue_message`` no longer unreserves (queue can't reserve).
- ``_resolve_attachment_ids`` had no remaining production callers
  and is deleted along with the tests that exercised it in
  isolation.

**Frontend.** ``Composer.setBusy`` disables the paperclip whenever
busy (regardless of ``queueWhileBusy``) — text still queues,
attachments don't. ``chat.css`` gains a ``.composer-attach:disabled``
rule (mirrors the existing ``.composer-send:disabled`` treatment)
so the affordance actually looks unclickable instead of falling
through to the UA default. ``title`` and ``aria-label`` are kept in
sync for AT users (WCAG 4.1.2).

Both interactive and coordinator UIs handle the new
``attachments_busy`` response with a chat-surface error bubble:

> Attachments can't be sent while the assistant is working.
> Send a text-only message now, or wait and resend with attachments.

Chips stay in the composer so the user can retry once idle.

**Tests.** Replaced the now-impossible ``TestQueuedWithAttachments``
class with a rejection-coverage class. Rewrote the
``_queue_with_attachment`` route-test fixture to reserve directly
via ``reserve_attachments`` (the queue path no longer reaches the
reserved state). Added a route-level test for the new
``attachments_busy`` contract.
2026-05-04 01:25:34 -07:00
Patrick Buckley 97fbfb9f8e feat: workstream attachments (images + text documents) (#356)
* feat: workstream attachments (images + text documents)

Adds end-to-end support for attaching images (png/jpeg/gif/webp) and
plain-text documents (markdown, source, JSON, etc.) to a workstream's
next user turn via the web UI.

Storage: new workstream_attachments table (migration 037) with a
three-state lifecycle — pending → reserved → consumed — scoped by
(ws_id, user_id) and linked to conversations.id on consume. Rewind/
truncation cascades attachment rows; delete_workstream does too.

Session: ChatSession.send(attachments, send_id) builds multipart user
content (text + image_url + document parts) and persists text-only to
conversations with attachments joined on load via message_id. Queue
path carries ordered attachment_ids plus a reservation token so
queued multimodal turns can't lose files to overlapping sends.

Providers: internal document content parts translate at the API
boundary — Anthropic emits native document blocks (text/plain
coerced, original MIME folded into title); OpenAI Chat Completions
and the Google OpenAI-compat endpoint inline them as escaped
<document> text blocks (XML-attr escape + </document> neutralization);
Responses API emits input_text with the same wrapper.

Server: POST/GET/DELETE /v1/api/workstreams/{ws_id}/attachments with
multipart upload (magic-byte image sniffing, UTF-8 enforcement for
text, per-kind size caps, Content-Length pre-check, per-(ws,user)
pending cap + TOCTOU lock). /v1/api/send reserves before dispatch
using a full-UUID token, threads it into session.send / queue_message,
releases on worker-thread failure, and reports attached/dropped ids
so the UI can reflect partial reservations. GET /content sets
X-Content-Type-Options, CSP sandbox, inline Content-Disposition, and
forces text/plain for text kinds. Ownership failures mask as 404.

UI: paperclip button, hidden file input with accept allowlist, chip
strip above textarea, drag/drop + paste-image handlers. Chips
rehydrate on ws switch and on queued-message dequeue; send clears
only attached ids and shows a toast when some dropped. Historical
user messages render filename pills via a _attachments_meta sibling
populated on both live-send and reconstruct paths.

530 tests covering CRUD, reservation lifecycle, races (TOCTOU cap,
reserve-then-dispatch overlap), provider translation, XSS headers,
cascade delete, history round-trip, and service-scoped actor flow.

* fix(attachments): address PR review feedback

- get_attachment_content now scopes the row by user_id too, so an
  unowned workstream can't be a vector for cross-user blob fetches
  via attachment_id guessing (Copilot, server.py:2676)
- send_message rejects attachment_ids lists longer than the pending
  cap with 400 — prevents hostile clients from blowing up the
  storage IN (...) clause (Copilot, server.py:1515)
- _attachment_upload_locks switched to a bounded LRU OrderedDict;
  evicts the oldest unlocked entries past the soft cap so the map
  can't grow unboundedly on long-running nodes (Copilot, server.py:2417)
- Pane.dragleave handler uses relatedTarget instead of target so the
  drop-zone styling clears correctly when the cursor moves through
  child elements; dragend listener added as a fallback for cancelled
  drags (Copilot, app.js:297)
- uploadAttachment always cleans up the placeholder chip on failure,
  including auth errors — no more stuck "uploading..." chips after
  re-auth (Copilot, app.js:427)
- New _swapPlaceholderChip / _removeAttachmentChip helpers preserve
  user-selection order through the placeholder→real-id swap; the
  pendingAttachments Map is rebuilt in place rather than naïvely
  delete+set, which would have moved the entry to iteration end
  (Copilot, app.js:420)
- Drop unused `var self = this;` in removeAttachment (github-code-quality)
- Two regression tests: cross-user fetch on an unowned workstream,
  and oversized attachment_ids list rejection

* fix(attachments): switch upload-lock to threading.Lock to avoid 3.12 CI hang

The per-(ws, user) upload lock was a module-cached asyncio.Lock.
Starlette's TestClient runs each request on a fresh anyio task /
event loop, so the cached lock's internal _waiters bind to the first
loop that acquired it.  When a later request runs in a different
loop, await lock.acquire() blocks on a Future from a closed loop —
silent deadlock.

This surfaced as test (3.12) hanging indefinitely in CI on one push
while the same suite passed on 3.11/3.13 and on the next push.  Same
root cause is reproducible against any Starlette TestClient harness
on 3.10+; 3.12 just happens to surface it more often given changes
in how anyio + asyncio.Future interact across loop teardown.

Switched to threading.Lock — loop-agnostic, and the critical section
is one COUNT + one INSERT, short enough that briefly blocking the
event loop is fine.  Updated the LRU-eviction probe accordingly
(threading.Lock has no public .locked(), so use a non-blocking
acquire+release as the "is it free?" probe).

TOCTOU pending-cap test still passes; full attachment suite passes
on both 3.12 and 3.13.
2026-04-15 13:30:22 -07:00