Commit Graph

124 Commits

Author SHA1 Message Date
Patrick Buckley 480a1426b3 Fail-closed history-commit handoff (#1005)
* fix(session): fail-closed history-commit handoff (#981)

The deleted-workstream discovery is now a terminal, ws_id-keyed latch:
keyed conversation commits refuse admission once the durable parent is
gone (convergence finalizers and force-abandon are exempt), history
handoff refuses to mint a proof token so /history fails closed with a
503 instead of silently wiping the pane, and the SSE stream carries a
workstream_gone resync reason. Discarded commits leave a forensic log
of commit keys and roles, never content.

Conversation rows gain a commit_key (migration 071): keyed saves are
idempotent under retry, validated against the full commit identity, and
refused when they would cross a workstream deletion. The prune orphan
category now requires a NULL alias plus a two-hour updated grace, with
cutoffs computed at discovery time and carried into both dialects'
rechecks.

The mid-turn interjection queue is owner-partitioned with no per-site
mode flags: pops take the acting principal's and unowned rows, other
participants' rows are structurally retained, and enforcement lives at
queue admission plus the shared before_spawn gates. The retraction
ledger is bounded by open pop windows: pops open a window atomically
with the queue delete, restores close their ids atomically with the
ledger consume, every other exit closes through one helper, and misses
for unheld ids record nothing. The workstream-gone latch refuses
unattended wakes at all three gates (watcher spawn, claim, delivery
pre-pop), and the retry dispatcher regained its pre-envelope
cancel/error convergence net.

Persistence-state reporting derives through the session bound to each
UI instead of a registry lookup by id that failed open to healthy
during tombstone retention. The dashboard roster no longer re-inserts
ghost entries from trailing activity events, the history tool-outcome
scan tolerates interleaved non-turn rows, and the shared
handoff-deadline handle owns its own retirement.

Single-sourced across call sites: keyed-commit row values, attachment
save wrappers, tail-truncation and conflict-resolution bodies for both
storage dialects; worker-slot lifecycle field sets; the direct-commit
admission frame; queued-row layout accessors; the string-aware comment
stripper shared by every JS harness suite.

Refs #981 #964

* fix(session): sweep handoff fixes to their sibling surfaces

The interactive replay loop treated a system row as a tool-batch
boundary, so every tool result after an interleaved row vanished from
that pane while the coordinator rendered the same history correctly.
Only a conversational turn ends the batch window now, matching the
shared outcome index.

Accepted user turns clear the composer's attachment chips on the same
viewer policy that settles optimistic bubbles rather than on having
matched a local bubble, so a workstream created with an upload no
longer keeps a chip for an attachment the create dispatch already
consumed. The coordinator's raced-Stop arm emits the stream-end hook it
inherits alongside the idle state, leaving no unfinalized bubble or
unflushed tool output. Ending a session surfaces a failure toast when
the request never lands or answers with a non-JSON body.

The per-second persistence reconcile now probes each session without
blocking: a workstream whose generation and handoff locks are held is
skipped until the next pass instead of contending the locks every
commit needs. The one-shot repair that gates workstream creation at
capacity keeps a definite probe — it has no next pass, and the sessions
likeliest to be contended are the ones whose unresolved journals
emptied its candidate list.

Single-sourced: the attachment lane builds its conversation row through
the shared commit-identity builder; the ordinary worker exit releases
its slot through the lifecycle owner; both operator surfaces snapshot
their counters through one non-consuming helper; the replay preamble
loses its per-kind wrappers and its config hook; the browser harness
suites share one brace walker; and each in-flight history attempt is
one record carrying both its abort controller and its deadline.

Refs #981 #964
2026-08-11 04:18:36 -07:00
Patrick Buckley 766223e774 feat(judge): parallelize batch evaluations (#991) 2026-08-08 23:56:15 -07:00
Patrick Buckley 98e96ab5f3 Add per-alias model concurrency admission (#990)
* feat(models): add per-alias concurrency admission

Add registry-backed FIFO admission limits with queue-aware deadlines and full-stream leases. Expose max_concurrency through storage, admin configuration, OpenAPI, documentation, and diagrams, with role and live backend count coverage.

* fix(api): omit null concurrency schema default

Keep max_concurrency optional for presence-keyed updates without advertising a null default for its non-null integer OpenAPI shape.
2026-08-08 22:01:04 -07:00
Patrick Buckley 7a06f5e8bc refactor(session): make ModelLane the provider boundary (#979) (#989)
* refactor(session): make ModelLane the provider boundary (#979)

## Summary

This closes the model-lane ownership gap left by #832: `ChatSession` no longer stores raw provider/client handles. `ResolvedModelBinding` now carries the provider, client, model, capabilities, registry generation, and backend-auth configuration as one coherent snapshot.

- Atomically rebind existing sessions after model-registry changes while pinning each in-flight send, fallback, judge, output guard, task agent, title, compaction, perception, and voice operation to its initiating principal and binding.
- Fence UI publication, canonical trajectory folds, durable writes, streams, retries, child scopes, and judge work by generation. Stop can hand off to a successor without accepting late state; cancelled tools retain typed effect receipts, and concurrent approval batches resolve by exact cycle or call.
- Make create, fork, open, close, and delete race-safe with hidden `creating` reservations, incarnation-aware state tails, and an ACL-rechecked transaction that clones checkpoint-bounded history, configuration, project/persona state, and attachment references.
- Extend REST/OpenAPI and Python/TypeScript SDK contracts for create/fork inputs, routed-create metadata, live-workstream probes, targeted approvals, and structured cancellation results.
- Update architecture, storage, authentication, judge, channel, console, API, and SDK documentation, including regenerated architecture diagrams and OpenAPI artifacts.

## Validation

- SQLite suite: 11,188 passed, 9 skipped, 10 deselected
- PostgreSQL suite: 11,195 passed, 2 skipped, 10 deselected
- Live backend: 3 passed
- SSE recovery: 6 passed; browser recovery harness passed all scenarios
- Ruff: clean; 595 files correctly formatted
- mypy: 243 source files clean
- TypeScript: typecheck/build and 35 tests passed
- OpenAPI artifacts fresh; all 14 changed diagrams reproduce byte-for-byte
- `git diff --check` and Git LFS integrity clean

Closes #979.

* fix(deps): update nanoid for GHSA-2v37-7h3g-55p8

Refresh the transitive lock entry admitted by PostCSS so the TypeScript security gate no longer resolves the vulnerable custom-generator implementation.

Validation:
- npm ci
- npm audit --audit-level=moderate: 0 vulnerabilities
- TypeScript typecheck and build
- TypeScript tests: 35 passed

* fix(test): assert canonical model registry URLs

Replace prefix checks with exact canonical base URL assertions so the tests do not model incomplete URL validation.

Validation: tests/test_model_registry.py (185 passed); Ruff check/format; mypy.
2026-08-08 16:13:35 -07:00
Patrick Buckley 06ec1a8629 fix(832): the boundary carry belongs to the run owner
The mandated cross-lane interleave angle found the two residual holes in
the reasoning-boundary close: the close was gated on not-in_think, so an
open inline think block at the boundary never closed and the later state
flip relabeled held chain-of-thought as displayed ANSWER text; and the
carry parked in the splitter's own pending was re-read under whatever
state later flushes hit, relabeling a content-state tail as reasoning.
close_run() now closes unconditionally (as the drain does) and RETURNS
the partial-tag tail; the consumer owns the carry in a state-immune slot
mirroring the drain's separate variable — re-fed when content resumes so
a split tag still reassembles, flushed as content at tool, finish, and
cancel boundaries, and included in the partial-content rule.

The trailing citations footer is now HELD and folded once at stream end
over the full answer — structurally the drain's post-loop fold — instead
of folding at arrival, which diverged from the commit whenever a lax
gateway emitted content after finish.

Two non-mirror fixes: the fallback-failure UI line carries the exception
class only (its text can embed a credential-bearing base_url; detail
goes to the server log, same rule as the re-issue log arm), and a
never-armed Stop (creation window, no prior death, zero tokens) writes
NO assistant row again — restoring pre-fold semantics; a marker-only row
would replay to the model as context on every later turn. Armed
zero-token Stops still record their marker.

Hygiene riding along: the parity runner zeroes the ladder backoff (the
exhaust scenario was sleeping 3.2s of real backoff per suite run, with
the retry-notice transform strings updated in step); test_session's
porting docstring points at the helper's real module; test_cancel and
test_session wrap the shared session factory instead of re-implementing
its defaults; arm_session's armed handle is an ArmedHandle with real
closed state instead of a MagicMock that satisfies any assertion; and
send() derives the tool-call list once for both the persisted mirror
and the executed set.

All fixes are mutation-probed: re-gating the close, discarding the
carry, dropping the promote gate, unredacting the fallback line, and
restoring the arrival-time fold each fail their pins.
2026-08-06 01:04:32 -07:00
Patrick Buckley aa4371ea99 fix(832): retire the dead attempt's armed state in the re-create window
Between a mid-stream death and the next begin_attempt there is no live
attempt, but the consumer kept the dead attempt's armed _CancelRef: a
Stop in that window re-emitted the discarded splitter carry as fresh
content behind a duplicate stream_end, and a walk-preamble failure was
classified as another armed death, replacing the operator-actionable
stream-death error. end_attempt() now pronounces the attempt dead at
partial-capture; the consumer gains a single per-attempt initializer
(_reset_attempt), a lane-free constructor (one resolve_lane walk per
turn), and a saw-chunk classifier fallback so a never-arming adapter's
mid-stream death still classifies mid-stream instead of silently
double-rendering the same lane.

Wire-preparation failures are typed at the seam: model_turn wraps
prepare_wire raises in WirePreparationError, both walk arms forward it
verbatim (no health record, no fallback walk — a session-data fault
would otherwise paint every backend degraded), the fatal formatter gets
a dedicated branch, and the re-issue ladder's last-death mask exempts
it alongside BackendAuthUnavailableError so an auth outage mid-turn is
not misdiagnosed as a network flap.

Riding fixes: the tag-scan gate gets its single spelling
(lane_scans_inline_reasoning) shared by drain and display; the
citations fold's separator+gate become a shared pair in _protocol;
_build_main_lane stops passing config_store (dead derivation — the
session's own knobs replace both values it feeds); the debug wire dump
is ruled per-invocation (the overflow-recovery re-print is the dump
that diagnoses the recovery) and pinned; dead delegates
_ensure_tool_call_ids and _finalize_provider_blocks deleted; the parity
runner adapts to the pre-fold seam signature by inspection and refuses
to record a harness-shape TypeError as a baseline; the streaming
provider fakes move to tests/_session_helpers (their tree-wide home)
and test_cancel's duplicate helper is deleted; committed parity pins
restate their rulings in full; architecture.md's circuit-breaker
section is replaced by the real passive health-tracker story and the
send-flow diagram stops attributing tool-call assembly to the display
consumer; stale pre-fold names and ragged comment paragraphs cleaned.

New pins are mutation-probed: disabling end_attempt, the saw-chunk
fallback, the auth exemption, or the WirePreparationError arm each
fails its pin.
2026-08-06 01:04:32 -07:00
Patrick Buckley e103af94e7 test(832): port the seam-coupled suites to the folded architecture
Seventeen files, ~1,300 tests, re-pointed or redesigned per the triage
ledger's recipes: wholesale turn-scripting moves to ModelTurnResult
fakes; streaming-behavior suites drive the REAL wrapper+consumer+drain
path through armed provider fakes (tests/_parity_832.arm_session — the
eager cancel_ref append every real adapter performs, exception elements
for creation-phase failures, sequential per-turn scripts, and the title
lane quieted: a provider-level fake otherwise loses its one-shot script
to best-effort title generation, which is why the old tests patched at
the session level); kwarg-capture suites assert through model_turn's
create_streaming call with system-prepend-aware index math; delegate
wrappers retired by the fold re-aim at their model_turn module twins.
Old-architecture pins are replaced by their new-world equivalents rather
than deleted: no shared cancel ref exists (pinned), the handle slot and
per-attempt refs carry the cancel surface, the retry gate reads the
serving lane's provider, and a superseded generation's death exits send
silently as cancelled — a named delta: no arbitrary exception class
escapes an orphaned thread anymore.

Full suite: 10651 passed, 10 skipped. The wire-payload goldens pass
untouched — the fold's lowering composition is byte-equivalent on every
provider's request path, as designed.
2026-08-06 01:04:32 -07:00
Patrick Buckley 70165807c7 fix(reasoning): close the unmarked chain-of-thought leak, gate the tag scan by backend (#940) (#978)
Some serving setups emit model reasoning inline with no think tags and no
reasoning_content at all — nothing any parser can segregate (measured live
on the dev vLLM: 20/20 sampled completions, streamed and not, proxied and
direct). The drain seam correctly passes unmarked prose through, so it
became the artifact on every bounded-artifact lane: workstream titles
("Thinking Process:"), compaction summaries that were ~90% chain-of-
thought, and the web-fetch tool results #940 reports — which then ride
every following turn as context.

Three coordinated changes:

* Utility lanes ask for no reasoning. _utility_completion (title,
  compaction, web-fetch extraction) pins the alias's declared thinking
  toggle off and withholds every reasoning-effort channel — the relayed
  session knob, the lane rung, the definition default, and the graded
  template key — via lane_without_thinking / lane_thinking_suppressed,
  the same suppression omni transcription already used (now shared as
  thinking_off_template_kwargs). Measured end-to-end: the extraction
  that returned 3.7k chars of reasoning returns a 258-char answer.

* server_parses_reasoning capability. A backend that segregates
  reasoning into its own channel declares it, and the inline tag scan
  turns off on every lane: the drain seam, the interactive splitter
  (which now reads the ACTIVE stream's capabilities via the creation-
  time handoff register, never the primary alias's), and the title
  lane's cosmetic peel — so prose that merely quotes a tag can no
  longer be misrouted, and the utility suppression stands down where
  reasoning costs the artifact nothing. The built-in commercial
  capability tables declare it wholesale (known models and table-miss
  defaults); local compat lanes keep the passthrough default the scan
  exists for. Bool-typed capability overrides coerce string spellings
  instead of truthiness-flipping on hand-edited JSON.

* Title selection follows the prompt's contract, not line position:
  the last line within the word cap that ends in a word character —
  rejecting explanation sentences, sign-offs, parentheticals, and
  reasoning headings in any script (terminal punctuation carries
  unspaced scripts where whitespace word counts are meaningless) —
  else the last non-empty line. 20/20 captured live responses title
  correctly (9/20 before, unchanged since well before the seam
  unification: the old and new pipelines scored identically on every
  sample, so the regression source was the backend's output shape,
  not #965).

Also folded in from the review round: a think tag split across a
reasoning-delta boundary reassembles in the drain (partial-tag tail
carry; tool boundaries still flush), Turn.text joins text blocks with a
newline so multi-block answers stop fusing words in notification bodies
and every flattened read, the notify hook reads final_assistant_text
directly instead of through a one-line shim, web-fetch extraction uses
the shared _non_blank_or fallback, and the judge/output-guard suites use
real ModelCapabilities instead of truthy mock attributes.

Closes #940.
2026-08-05 12:58:55 -07:00
Patrick Buckley 0150523bb9 test(session): pin the both-vocabulary title peel
The title lane's cosmetic peel walks the close-tag vocabularies in
sequence, which review read as a double peel that could discard title
text between a `</reasoning>` and a `</think>`. It cannot: the remainder
of the first cut begins after the last `</think>`, so a `</reasoning>`
still found in it is necessarily the later tag — the sequence is
equivalent to one cut after whichever close occurs last (verified
exhaustively over tag/text arrangements and 200k randomized fragment
strings).

The equivalence was unpinned, so both orderings join the variants table
and the docstring records why the sequence is a single logical cut.
2026-08-05 00:23:11 -07:00
Patrick Buckley bc3fa60011 fix(providers): segregate inline reasoning at the drain seam
Passthrough servers (parserless vLLM/llama.cpp, LM Studio, bare
gateways) emit reasoning as literal <think>/<reasoning> blocks inside
content, and only three of nine drained lanes stripped them: web_fetch
tool results persisted raw think blocks into every following turn
(#940), judge verdicts parsed through tag noise, and a draft verdict
inside a think block could shadow the real one at the output guard.

One rule at the seam now. drain_stream accumulates content in RUNS
bounded by interleaving signals (provider-parsed reasoning deltas,
tool-call deltas) with the interactive consumer's within-chunk ordering
— reasoning, then content, then the tool-call close — and splits each
run through split_inline_reasoning, the one-shot form of the
interactive lane's ThinkTagSplitter: a pure raw split, exactly
equivalent to the streaming form on every catalog case. One trim policy
exists and the drain owns it: blank edge lines are trimmed once over
the joined runs when a tag was consumed, so tag residue dies at the
edges while genuine inter-run paragraph separators survive. Extracted
text is appended to result.reasoning after any server-parsed reasoning
with a blank-line boundary and rides the native lane as the
reasoning_text synth block. Orphan CLOSE tags deliberately pass through
byte-identical: a close whose open never arrived is indistinguishable
from prose QUOTING the tag, and drained lanes routinely quote
third-party text — reclassifying would let a malicious page containing
the literal tag destroy the extraction that cites it. The title lane
keeps a local rfind peel as display-string formatting. The citations
footer folds only onto non-blank content — sourcing for an answer that
does not exist is dropped rather than handed to emptiness checks as a
footer-only "answer".

Every private strip is deleted: the title lane's strip, the summarizer
strip, _strip_reasoning itself, and the optimizer's five regexes
(_strip_markdown_fence is now the one fence rule, applied to normalized
model output only, never to or-fallback values). Think-only and
whitespace-only responses drain to blank content, and every lane's
no-answer fallback gates on blankness: web_fetch returns an honest
extraction-error card, the intent judge takes the empty-retry ladder,
the task-agent synthesis reports "(no output)", and the optimizer keeps
the current observer system and prompt verbatim on no-answer passes.
Final-say reads (optimizer analyst, eval final_content, the notify
hook) use trajectory.final_assistant_text — the last assistant turn
only, never an earlier narration presented as the conclusion — while
last_assistant_text is the salvage walk (task_agent partial-work
recovery), skipping tool-call-only, all-reasoning, and whitespace-only
turns. Perception memoizes every completed description immediately,
including an empty one — one perceive per key, ever — under a
commit-lock guard so an empty result never overwrites a concurrently
memoized real description; an all-reasoning perception model pins the
placeholder until restart, and the remediation is server-side (a
reasoning parser or the template thinking toggle on the perception
alias). A true double-reasoning shape (inline-extracted text alongside
a native reasoning block) logs chars-only at the drain, where it is
distinguishable from the routine reasoning_delta mirror.

The dialect's semantics are pinned as one table
(tests/_reasoning_dialect.py) driven through shared fixtures
(think_tag_stream, seam_provider): one-shot conformance, the exact
one-shot/streaming equivalence property, the drain seam rules including
quoted-tag safety, run-boundary and separator-preservation pins,
per-lane pins for all nine lanes, and the empty-content assistant wire
shape.

Closes #965. Closes #940.
2026-08-05 00:23:11 -07:00
Patrick Buckley 5fb27e8f81 fix(session): survive mid-stream transport deaths in interactive turns (#937)
A wire death during body streaming (ReadError on a TLS record failure,
peer resets) surfaces after the request has already returned its stream
handle, so neither the SDK's request retries nor the creation-time
retry ladder ever saw it: the interactive turn died with a bare
exception string, the partial output was discarded, and no log trace
was left. Utility lanes already survived this through drain_stream's
normalization; the interactive loop now gets the same treatment.

- transport_guarded() in providers/_protocol.py: drain_stream's
  transport-death conversion made reusable for consumers that keep
  streaming semantics. Pre-finish deaths raise the retryable
  IncompleteStreamError (drain's exact message shape); post-finish
  blips end the stream cleanly, forfeiting only trailing metadata.
- The single-pass chunk consumer renames to _stream_attempt;
  _stream_response is now the resilient wrapper owning ALL stream
  acquisition plus a bounded mid-stream re-issue ladder
  (_MID_STREAM_RETRIES, the shared _stop_retrying predicate with a
  per-loop cap, cancel-aware exponential backoff). Send()'s overflow
  compact-and-retry arm now wraps the whole turn and passes re-prepared
  msgs explicitly.
- A dead attempt is finalized across every UI consumer before the
  retry (stream_end then turn_committed then notice then spinner), so
  retried text never appends onto the dead attempt's in any surface
  (browser transcript, CLI markdown fences, Slack/Discord streamed
  messages, SSE replay ring).
- Before re-creating, the session re-resolves its registry binding: a
  concurrent ModelRegistry.reload() closes cached clients, and the
  retry must not stream into the closed one. A failing re-create logs
  stream.retry.recreate_failed and re-raises the ORIGINAL stream-death
  error rather than masking it.
- _format_backend_error gains a stream-death branch naming the
  provider, endpoint, and model, with a short identity-bearing first
  sentence. _BACKEND_STREAM_EXC_NAMES joins _BACKEND_KNOWN_EXC_NAMES,
  which also removes those names from _is_ctx_overflow's text-detection
  eligibility (deliberate: their texts are fixed transport strings that
  never carry overflow phrases).
- _record_fatal_error now logs session.fatal.recorded (INFO for
  KeyboardInterrupt, ERROR otherwise) so fatal turns leave a journal
  trace.
- _assistant_pending_tokens resets at stream entry so a post-finish
  blip that loses the trailing usage chunk cannot append the previous
  turn's completion count as this turn's estimate.

Offline SDK boundary pins (openai/anthropic mid-body death identity and
no re-request, cross-thread client close surfacing httpx.ReadError)
guard the assumptions the retry gate rests on.
2026-08-04 04:53:17 -07:00
Patrick Buckley 33ace975d2 feat(models): default-deny governance and admin UI for per-alias backend auth
Follow-up to the per-alias Entra OBO/app-identity backend auth: the
console write path now applies default-deny field classification, the
admin shelf gains full backend-auth support, and the session/registry
rebind machinery is hardened for config changes landing under live
sessions.

Console write gate:
- Default-deny classification: any non-neutral change to a row that is
  or becomes dynamic requires admin.mcp plus validation; the provably
  auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a
  live-schema classification test forces every future column to be
  classified. The derivation is a pure function (_derive_auth_gate)
  with unit-pinned exclusivity invariants.
- Two-tier validation mirroring the MCP oauth_obo validator: the row
  tier (audience allow-list) runs on every gated write; the posture
  tier (OIDC configured, token store present) runs on pair changes and
  on enable-arming.
- Pure-disable carve-out: disabling a dynamic row is de-escalation and
  is never blocked — admin.models suffices and validation is skipped,
  including for rows with corrupt or skewed stored values.
- Capabilities are compared canonically (key order, integral floats),
  the audience compare normalizes both sides, and staging an audience
  on a static row is refused on both write twins.
- Calibrate writes the capabilities column under an enforced
  confinement invariant with a compare-and-swap persist.

Admin shelf:
- Backend-auth section with a per-open constraints fetch
  (GET /model-definitions/auth-constraints: audience allow-list, grant
  profile, dynamic modes), datalist audience suggestions,
  server-defined modes preserved on round-trip, and permission-aware
  visibility built on cache-skew-safe helpers shared through auth.js.
- Refused live-registry swaps surface as an amber registry_warning on
  the write, delete, reload, and calibrate responses; audit rows carry
  auth_gated / auth_disarmed markers visible in the audit view.

Registry and sessions:
- The encryption-key requirement for dynamic auth is enforced inside
  ModelRegistry.reload() itself — nodes refuse with 503 and the
  console records coord_registry_error — and reload bumps the
  generation before the map swap so a racing reader can never pair a
  stale generation with new maps.
- resolve()/resolve_binding() return the generation from inside the
  registry lock; sessions rebind per send on generation change with
  atomic client/provider/config commits, fallback-first handling of
  removed or unconstructable aliases, and judge/limiter resets only
  when the binding actually changed.
- Mint refusals record per-user causes surfaced in the per-turn
  heartbeat logs; misconfiguration warnings are deduplicated with
  bounded state.

Verification: 10417 tests (99 added on this branch), a 71-scenario
browser harness over the real admin shelf, and a live rfc8693
token-exchange e2e run (MCP legs verified end to end; the model-leg
scope gap is tracked as #955 under a narrow known-gap signature).

Closes #950.
2026-08-03 20:11:28 -07:00
Patrick Buckley 2ff7c61051 feat(coordinator): idle nudges deliver only on the idle wake, and a queued interjection owns the seam
The idle nudges enqueued on the any channel, which every drain seam
serves — a deferred wake left them deliverable at the start of a real
user send or mid-turn at a tool batch, describing an idle moment that
no longer existed. They move to a new wake channel: wake-eligible,
invisible to USER_DRAIN, TOOL_DRAIN, and the quiet ride-along. A user
cancel drops pending wake entries rather than demoting them — the
quiet demote's whole value is later seam delivery, exactly what this
class may never have. Dropping a charged entry is the accepted
fail-closed cost; liveness surviving Stop means the next idle event
fires fresh, not that a queued entry re-wakes the workstream.

A queued user interjection owns the idle seam: at wake delivery, a
non-empty interjection queue drops the wake-channel entries and the
interjection runs as a genuine user send in their place — no wake
tag, so the caps reset as for any real send and the next genuine idle
re-derives both nudges over fresh reads. The check lives in the wake
worker (which owns the slot and can dispatch a full send), not the
watcher's state-transition thread, where skipping would strand the
message. Measured before building: send('') with queued messages
appends an empty user turn and delivers the interjection one
assistant turn late, so the handoff pops first and sends the popped
text — one rendering shared with the flush seams.

External events are not idle nudges: any-channel entries still arm
the wake alone, and with an interjection waiting they ride the
genuine turn's drain seam — both deliver, only the idle nudges drop.
A failed wake send drops wake entries alongside user ones; externals
requeue quiet as before.
2026-07-29 22:11:38 -07:00
Sanjay Santhanam 82676080a4 fix(session): describe skill selections accurately
Use neutral "set" wording for operator skill markers so re-selecting the
current skill does not falsely claim a change. Update the regression
expectation for the persisted marker.
2026-07-17 00:58:37 -07:00
Sanjay Santhanam a64cd25807 fix(session): record operator skill changes
Operator-driven /skill changes were only shown in the UI, leaving no trajectory marker for the model. Persist a system turn for named skill changes and clears, with regression coverage for both paths.
2026-07-17 00:58:37 -07:00
Patrick Buckley 56b7674dfa fix(providers): review round 3 — in-band error events, terminal-marker tolerance, adapter-owned de-fusion
Correctness:

- Responses _iter_stream handles the SDK's in-band `error` SSE event
  (ResponseErrorEvent is YIELDED, not raised, and no response.failed
  need follow): the real API code/message now surfaces — code-gated for
  retryability like response.failed — instead of the stream exhausting
  finish-less and hiding the cause behind a retried
  IncompleteStreamError.
- Anthropic message_stop supplies a missing stop_reason: it is a genuine
  terminal marker, so a compat /v1/messages shim whose message_delta
  omits stop_reason completes (blocks intact) rather than failing a
  generation that arrived — tolerance the retired non-streaming default
  provided, restored without weakening the died-mid-response gate.
- A Responses terminal event without its response payload still emits
  the finish reason its type implies (lax compat servers), losing only
  usage/blocks rather than the whole result.

Dispositions held (documented, not re-coded): the complete-or-error
gate stays for finish-less Chat Completions streams — indistinguishable
in-band from a died generation, and silent partial-storage is the worse
failure; CHANGELOG now names the shape and each provider's accepted
terminal markers. supports_streaming deletion and the stream_options
wire delta were ruled earlier and keep their release-note remediations.

Cleanup: index-degenerate de-fusion MOVED from drain_stream into the
chat adapter's iterator (mirroring the Anthropic iterator's index
assignment) so the interactive loop is fixed too and the drain returns
to a plain mirror of the main-loop accumulator; a parametrized test
locks "IncompleteStreamError is retryable" across all six provider
lanes instead of trusting per-adapter memory; scripted_anthropic_client
joins scripted_chat_client (shared _ScriptedClient class, no function
attrs) and the two remaining hand-rolled anthropic closures convert.
2026-07-13 22:39:19 -07:00
Patrick Buckley 3ffa8b9057 fix(providers): review round 2 — complete-or-error drain, code-gated retries, truncation-safe blocks
Correctness (3 confirmed + 2 plausible, all fixed):

- drain_stream now raises typed, retryable IncompleteStreamError when a
  stream exhausts without any finish reason — every adapter emits one on
  a healthy stream, so its absence means the generation died
  mid-response behind a cleanly-closing proxy.  This restores the
  retired transport's complete-or-error contract (a half-generated
  compaction summary was previously returned as finish=stop and stored,
  silently replacing real history) and DELETES round 1's suffix-info
  fold: with no finish-less success path there is nothing to classify,
  so a trailing status ping can never be stored as content either.
- Index-degenerate parallel tool calls get distinct slots: a delta whose
  id differs from its slot's opens a new call (id-less fragments still
  follow their index's current call), so historical compat servers that
  emit every parallel call at index 0 no longer fuse distinct calls
  into concatenated garbage arguments.  Result order stays index-sorted
  (stable) like the retired array parse.
- response.failed retryability is code-gated: only transient codes
  (server_error, rate_limit_exceeded) raise the retryable typed error;
  deterministic rejections (invalid prompt, image fetch, policy) raise
  plain RuntimeError and stop retry loops on attempt zero instead of
  running the full backoff ladder against a doomed request.
- Terminal Responses events rebuild provider_blocks from
  response.output when present: the item being generated at
  max_output_tokens truncation never receives output_item.done, and
  storing a reasoning item without its required following item made the
  next turn's replay a 400.
- merge_usage's base case uses dataclasses.replace so a future UsageInfo
  field can't be silently zeroed on drained lanes.

Cleanup: run_abortable_with_deadline bundles the three-point abort
wiring (ref + cancel_ref + on_abandon) so it cannot be half-wired —
both judges converted; scripted_chat_client hoists the 14 chat-lane
fake_create closures (call scripts + .calls recording replace per-test
counter cells); fake_chat_stream gains reasoning=, collapsing the
reasoning-capture suite's hand-rolled chunk shape; FakeAnthropicBlock
hoists the duplicated _Block test class; the class and judge PlantUML
diagrams drop the retired create_completion flow.

Also converts test_model_registry's agent-model fakes, which returned
legacy response objects that iterated as EMPTY streams — they only
passed through the old drain's silent finish=stop default, exactly the
hazard the new gate exists to catch.
2026-07-13 22:39:19 -07:00
Patrick Buckley 1e7ad7bcb6 feat(providers): one transport — drain create_streaming, retire create_completion (#831)
Every single-shot lane (model_turn: judges, titles, compaction, web-fetch
extraction, perception, eval, optimizer) now samples through the provider's
streaming entry and accumulates via a shared drain_stream(), deleting
create_completion from the Protocol and all three adapters (xai/google
inherit). Request shaping can no longer drift between the two consumption
styles, and callers keep the exact CompletionResult contract.

The drain mirrors the main loop's proven chunk semantics: per-field
max-merge for usage (Anthropic splits prompt/completion across
message_start/message_delta), tool-call assembly by delta index,
provider_blocks from the terminal emission, trailing citation info folded
back into content (byte-matching the old format_citations append),
mid-stream status pings dropped.

Also in this change:

- model_turn grows cancel_ref; both judges wire their run_with_deadline
  abandon paths to a new StreamAbortRef (deadline.py) that closes the SDK
  stream — a timed-out judge call now aborts its HTTP read instead of
  pinning a daemon thread until the next upstream chunk. The append hook
  covers the arrival race, mirroring ChatSession._CancelRef.
- Responses streaming gains the response.incomplete terminal handler
  (truncated runs were mislabeled finish=stop and lost final usage AND
  collected provider_blocks) and a refusal handler ([Refused: …] content,
  matching the retired non-streaming rendering). Both also fix the main
  chat loop, which shared the gaps.
- supports_streaming capability flag deleted (zero readers) along with
  its admin capability tile; o1-era models that reject streaming need a
  model alias pointing at a current model (release-noted).
- Helpers that existed only for the deleted transport go with it:
  Responses._parse_response, chat/google._extract_tool_calls.

Known behavioral deltas (release-noted): OpenAI-compatible servers that
ignore stream_options.include_usage stop producing usage rows on these
lanes; multiple Anthropic text blocks concatenate without the old "\n"
joint (matching the main loop); model_turn lanes no longer risk client
read-timeouts on long generations — the reason the Anthropic adapter
already drained a stream internally.

Tests: new test_drain_stream.py pins the accumulator rules; shared fakes
(as_stream, fake_chat_stream, fake_anthropic_stream) migrate 11 suites to
the streaming transport, with the task-agent and adapter suites now
exercising the real _iter_stream + drain path end to end.
2026-07-13 22:39:19 -07:00
Patrick Buckley b6391d1f90 fix(model-turn): one sampling-knob assignment scheme — alias > config > model definition > omit
Round-2 review fixes. The round-1 de-pinning collided with
ConfigStore.get's default-on-miss semantics: the registry defaults
(temperature 1.0, effort "medium") were manufactured onto every
store-backed lane's wire, making the documented "unset -> omit"
terminal unreachable. Unset is now representable end to end, and one
scheme governs every lane: per-model alias value > operator-stored
global setting > in-code model definition (effort only: caps
declaration) > field omitted, inference engine's default rules.

- settings_registry: model.temperature default None, model.reasoning_effort
  default "" — the registered defaults ARE the unset sentinels, so the
  admin UI and the wire agree. Admin webux renders nullable floats blank
  ("(inherit model default)") and maps blank-save to reset; the "" effort
  choice reads "(inherit)".
- model_turn: resolve_temperature_setting/resolve_effort_setting are the
  ONE pair of operator-rung resolvers, shared by resolve_lane, both
  session factories, and the /model switch (the 4th-copy mirror is gone;
  the switch no longer leaks the previous model's override on store-less
  sessions). The caps rung moved out of the lane into model_turn's
  effective computation, below a new request-shaped default_reasoning_effort
  parameter (utility + output guard pass "low": budget coherence with
  their small token caps, not sampling policy — any operator or
  model-definition value beats it). The hidden "medium" terminal is gone.
- providers: Protocol + all adapters take reasoning_effort: str | None =
  None (the Protocol-signature "medium" was the same manufactured pin one
  layer down); ModelCapabilities.default_reasoning_effort defaults "" —
  commercial rows all declare theirs explicitly, so only local lanes and
  Anthropic change, both to match their real serving defaults (Anthropic
  manual-thinking models no longer get implicit thinking-on-medium).
  reasoning_template_kwargs distinguishes unset (inject nothing; template
  default rules) from the explicit "none" off-switch. apply_temperature
  skips temperature unless reasoning is EXPLICITLY off on none-declaring
  models (unset leaves the server default in charge, possibly reasoning-on).
- session: ctor takes temperature: float | None / reasoning_effort:
  str | None = None; _save_config/resume round-trip unset as "" (the
  str(None) era guarded); _run_agent relays session temperature AND
  effort on the same-alias fall-through only (a task alias's configured
  knobs stay reachable in both directions).
- optimizer: the five meta lanes are decoupled from --temperature/
  --reasoning-effort (test-model knobs, per their documented meaning);
  registry-less meta lanes omit both fields.
- cli: --temperature/--reasoning-effort default unset and fall through
  the model config instead of pinning 0.5/"medium" for every CLI session.
- cleanup from the review's below-cap findings: dead resolve_server_type
  deleted (tests re-pointed at _server_type_of), stale ChatSession
  comments in _openai_responses fixed, _store_get_or_none extracted,
  eval system-turn conversion hoisted out of the per-turn loop, dead
  _provider_extra_params patch removed, test_perception uses the shared
  mock_completion_result, effort_ladder uses apply_capability_overrides
  instead of a SimpleNamespace fake config.

Wire goldens regenerated: the only drift is the manufactured "medium"
effort vanishing from unset-effort requests (Responses reasoning.effort,
Chat/Google reasoning_effort, Anthropic output_config.effort) — pure
removals, no additions. Ladder tests now fake ConfigStore with the REAL
get() semantics (registry default on miss) so a forgiving fake can't
mask this class of bug again.
2026-07-13 08:48:27 -07:00
Patrick Buckley 3eb789dfff fix(model-turn): temperature truly inherits — None never reaches the wire
The second xhigh review caught the fix-round design error one layer
down: omitting the temperature kwarg did not yield the server default —
every adapter's create_completion signature defaulted it to 0.5 and
apply_temperature wrote it to the wire, so the deleted lane pins had
silently become a hidden universal 0.5 pin.

The house rule is now implemented end to end:

- Protocol + adapters take temperature: float | None = None, and None
  is OMITTED from the wire (apply_temperature None-gate; Anthropic's
  builder keeps its API-required thinking=1.0 forcing but never writes
  an unresolved value; Responses/xAI builders widened).
- resolve_lane climbs the documented ladder: ModelConfig.temperature →
  ConfigStore global model.temperature (new config_store param,
  threaded from ChatSession into both judges and perception) → None.
- perception.describe/describe_cached take alias/registry/config_store
  so operator settings on the perception alias actually reach the wire
  (previously structurally unreachable — no remediation path for a
  degraded memoized description).
- The agent seam stops relaying the SESSION model's temperature: the
  task/agent alias's own ladder governs, per the inherit-from-the-model
  contract.

Generation-coherence and audit fixes from the same review:

- ChatSession._resolve_capabilities fetches its config UNCAUGHT again —
  a registry failure on the session's own alias raises loudly instead
  of silently caching degraded static-table caps for the session
  lifetime (the never-crash fetch is a judge-constructor property).
- Judge constructors pass cfg=model_cfg (zero independent get_config
  fetches; pinned by test); the per-evaluation lane's constructor-
  frozen capabilities are documented as deliberate (window-coupled,
  refreshed on judge swap).
- OutputGuardJudge splits _lane_alias from _judge_model_alias so the
  audit label keeps its pre-#827 fallback semantics ("" → raw model id)
  while lane resolution inherits the session alias.
- model_turn fetches the alias config ONCE per call and threads it into
  both live flags (cfg sentinel standardized across the resolvers:
  ... = fetch for me, None = fetched-and-missed — also removes
  resolve_lane's latent double-fetch on a miss).
- cap_tool_calls shared by the eval and optimizer loops; hand-built
  ModelLane sites converted to resolve_lane; hand-rolled test result
  namespaces consolidated onto mock_completion_result; stale synth-test
  module docstring re-pointed.
2026-07-13 08:48:27 -07:00
Patrick Buckley 7e07f2ea93 feat(core): phase 2 — every single-shot lane speaks Turn IR (#827)
create_completion now has exactly one caller: model_turn. The π-side
lanes migrate off hand-built OpenAI dicts:

- _utility_completion (title gen, compaction, web-fetch extraction)
  takes list[Turn] and runs the session's primary lane through
  model_turn; its three call sites build Turn.system/Turn.user.
- perception.describe builds a by-reference trajectory (AttachmentRef +
  the prebuilt parts via resolve_attachments, reintroduced on
  model_turn with its first caller and pinned by tests) — Turn IR never
  carries inline media bytes, matching the main loop's wire path. Its
  temperature=0.2 pin is gone (house rule).
- eval HeadlessSession's loop lowers system prompts through the
  turns_from_dicts bridge and appends result.turn; the parallel-call
  cap now also drops the native lane on a capped turn (a capped mirror
  with a full native lane would replay orphan tool blocks).
- optimizer: all five sites (diversifier, observer, analyst loop,
  tool optimizer, prompt optimizer) build Turn IR through per-function
  lanes; every temperature pin (0.8/0.3/0.3/0.3/0.6) removed per house
  rule — sampling behavior belongs in the model's configuration.

Test mocks move to the shared full-shape helper where the model_turn
re-ingest now runs; perception/attachment tests assert the
by-reference placeholder + resolver contract instead of inline parts.
2026-07-13 08:48:27 -07:00
Patrick Buckley b0937683ae fix(model-turn): apply the #827 phase-1 review round
Behavior fixes, per review + house rules:

- Judges no longer pin temperature=0.0 — the lane inherits the model's
  configured temperature (ModelConfig.temperature via resolve_lane), and
  model_turn omits the kwarg entirely when nothing resolves. House rule:
  code never pins a temperature; modern models often misbehave below
  1.0, so the model's configuration is the source of truth. This also
  dissolves the extra_body-overrides-judge-pins collision: operator pins
  reaching the judge lane is the doctrine working.
- Session-fallback judges inherit the session's registry alias
  (session_model_alias threaded from ChatSession), so the registry-
  resolved extra_params / replay flag / vLLM attach apply on the default
  judge.model-unset configuration instead of only on explicit aliases.
- Blank-id native lanes are repaired, not dropped: model_turn backfills
  the manufactured mirror ids into blank-id native client tool blocks
  pairwise (the #825 1:1 ordering invariant), so thought_signature
  survives Google's blank-id compat responses and thinking blocks keep
  their continuity on blank-id locals. Only blank ids are ever written —
  a provider-assigned id (possibly signature-covered) is never touched —
  and any pairing mismatch falls back to the #825-converged total drop.
- model_turn(mint=...) without wire_id_map now raises: minted ids are
  unrestorable without the recovery map, and the two parameters were
  independently optional by accident.
- Lane resolution reads ONE defensively-fetched ModelConfig
  (_get_config_or_none): a registry hot-reload mid-resolution can't mix
  config generations, and an alias that raced away degrades each facet
  to its miss behavior instead of aborting a judge constructor into the
  silent session-model downgrade.

Extraction hygiene, per review:

- Dead session wrappers deleted (_resolve_server_type,
  _maybe_synth_reasoning_block, _get_server_compat) and their tests
  re-pointed at the module functions; the stranded reasoning-types
  comment and two stale doc pointers cleaned up.
- Speculative extra_headers / resolve_attachments pass-throughs dropped
  from model_turn until a caller lands (phase 2/4 reintroduces them
  with their lane).
- _server_type_of(cfg) is the one reader of server_compat.server_type;
  the vLLM-attach gate and resolve_server_type both use it, retiring
  the change-both-readers discipline comment.
- dataclasses import hoisted; module docstring restated as the durable
  contract (grep callers for coverage) instead of a rotting snapshot.
- mock_completion_result shared in tests/_session_helpers.py — one
  definition of "every field the re-ingest reads".
2026-07-13 08:48:27 -07:00
Patrick Buckley ab35eb4215 refactor(core): extract model_turn, the shared plant-call primitive (#827)
Lower-and-sample is now one surface: core/model_turn.py owns the
Turn-IR lowering seam (dicts_from_turns -> sanitize_tool_call_arguments
-> restore_provider_tool_ids -> Phase 5 vLLM attach), the provider call,
and the re-ingest to an assistant Turn carrying the native lane.
ModelLane binds a resolved lane (provider, client, model, capabilities,
extra_params) and carries the registry so live operator toggles
(replay-reasoning, vLLM attach) keep re-resolving per call.

The task-agent seam is the first client: _run_agent builds a ModelLane
and calls model_turn with a mint closure; the inline mint/back-fill/
finalize block collapses to appending result.turn. Session capability/
extra-params/replay/finalize helpers become delegates to the module
functions, so lane resolution has exactly one logic path.

model_turn is policy-free by contract: retry, deadlines, tool
execution, and usage recording stay with each caller.

Two agent-path tests move their replay-flag pin to the module seam
(one had gone vacuous against the session wrapper); _record_aux_usage
now takes UsageInfo rather than a CompletionResult.
2026-07-13 08:48:27 -07:00
Patrick Buckley 98cefc3660 fix(task-agent): move the blank-id gate into the shared native-lane builder
The blank-provider-id gate lived only at the _run_agent call site while
the main-loop stream accumulator has the identical back-fill-then-carry
seam — and it over-dropped, discarding the reasoning lane for exactly
the servers that emit blank ids. The gate now lives in
_finalize_provider_blocks as a had_blank_ids parameter both harnesses
thread: client tool blocks (which keep the blank id the mirror back-fill
never reached) are stripped, and when any were present the remaining
Messages-shaped blocks go with them (a surviving native lane REPLACES
the rebuilt content on the Anthropic translator, so a lane missing its
tool_use would orphan every mirrored call) — while shape-invalid
reasoning residuals (reasoning_text, Responses reasoning items) are
kept. This also closes the pre-existing main-loop case: a Gemini
openai-compat turn with a blank tool id no longer persists a raw
fidelity dict whose blank id the swap would resurrect on every replay.

The Google fidelity-swap legalization now reuses the canonical
lowering.legalized_arguments (made public) instead of a hand-rolled
narrower copy: dict-shaped arguments are serialized rather than
collapsed to {}, the standard wire.tool_args_legalized breadcrumb is
logged, and a degenerate non-dict function entry passes through
untouched instead of raising.
2026-07-11 16:37:13 -07:00
Patrick Buckley 646bceed52 fix(task-agent): review fixes for the native-lane carry
- Skip the native lane on a turn whose provider left a tool-call id
  blank: the uuid back-fill reaches only the tool_calls mirror, so a
  carried native tool_use block would replay the blank id and desync
  from the restored tool_result (Anthropic orphans the result; Google
  re-fills a fresh uuid). The rebuild path keeps every representation
  on the back-filled id — the pre-native behaviour, for exactly the
  degenerate case.
- Extract _reasoning_text as the ONE Chat-Completions reasoning
  extractor shared by the streaming and non-streaming paths: first
  non-empty STRING of reasoning/reasoning_content wins, so a server
  putting a structured object in reasoning can neither shadow valid
  text in reasoning_content nor leak a non-str into the session's
  reasoning accumulator.
- Legalize arguments when GoogleProvider's fidelity swap replaces the
  sanitized tool_calls mirror with the raw provider dicts — the swap
  could resurrect a malformed arguments string the upstream sanitize
  pass had fixed (pre-existing on the main loop; ids and
  thought_signature untouched).
- Drop the redundant emptiness guard on the agent seam's
  reasoning_parts (the shared finalize helper already guards) and
  document the wire_id_map lifetime invariant for future
  resumable/background agents.
2026-07-11 16:37:13 -07:00
Patrick Buckley d660819142 feat(task-agent): carry the provider-native reasoning lane in the sub-harness
A task agent's replayed turns now carry the native reasoning lane the
model produced (Anthropic thinking blocks + signatures, OpenAI Responses
reasoning items, Gemini thought_signature blocks, vLLM/llama.cpp parsed
reasoning text) instead of being rebuilt from content + tool_calls with
the reasoning dropped — restoring reasoning continuity across the
agent's own multi-turn tool loop on every provider lane.

The prerequisite is the id half: replace legalize_tool_call_ids with
restore_provider_tool_ids, a lowering pass that maps the session-minted
sub-tool ids back to the provider's own ids on the transient wire copy
(from the per-run mint map, never by string-splitting). The native
tool_use block is replayed verbatim — its id and signature untouched —
and the top-level mirror and tool_result agree with it on every request.
The minted id stays the sole internal key (registry, DOM, recall,
cancel ledger), #820 unchanged.

Chat-Completions lane: non-streaming create_completion now surfaces
reasoning/reasoning_content as CompletionResult.reasoning (the twin of
the streaming reasoning_delta extraction), and the agent seam runs the
Phase 5 vLLM reasoning-field replay against the agent's own provider
and alias. The native lane is finalized by a shared helper
(_finalize_provider_blocks) so the main loop and the sub-harness cannot
drift; replay honors the per-model replay_reasoning_to_model flag on
every lane, and llama.cpp stays capture-only, matching the main loop.
2026-07-11 16:37:13 -07:00
Patrick Buckley 110d6b4fc0 fix(task-agent): mint session-unique sub-tool ids
Sub-agent tool ids were namespaced {parent}::{provider_id} — unique
across concurrent agents but not across turns within one agent. A local
provider reissuing "call_0" every response minted the same id twice, so
the live card's DOM row lookup collapsed distinct calls onto one row
while FIFO recall kept them apart: two views of one trajectory disagreed
on identical input (the bug-3 id-consistency defect). When the provider
also reuses the PARENT call id, sequential runs repeated the collision
one level up.

Mint {parent}::r{run}s{step}::{provider_id} at the single rewrite point:
a session-monotonic run tag (lock-allocated; runs start concurrently on
the 4-wide task pool) plus a per-run step tag make each id unique within
the session, and every consumer — nesting registry, error flags, DOM
data-call-id, recall projection, cancel ledger — keys on that one id.
The FIFO pairing helper stays as honest pairing for un-minted input
(unparented runs, direct construction), with its rationale rewritten.

The agent wire seam (_run_agent's _api_call) also runs the same two
validity passes the main loop already ran — sanitize_tool_call_arguments
(a documented vLLM deepseek_v4 renders malformed args and 400s; agents
hit the same backends) and legalize_tool_call_ids (projects the long,
::-containing ids to plain tokens, call/result pairing preserved). The
id projection is DEFENSIVE hardening, not a fix for an observed break:
the ids replay fine on the lenient anthropic-compatible deployment (the
prior ::-containing format ran reliably), it just keeps an agent's
self-built history valid on a hypothetically stricter backend. Applied
at the agent seam only — main-loop assistant turns carry a provider-
native block lane whose id must stay byte-identical to the mirrored
tool_calls, so the projection cannot run there without desyncing them.

Follow-ups: parent-level card aliasing under a reused parent id; the same
id hygiene for the main conversation loop / native lane.
2026-07-11 13:12:27 -07:00
Patrick Buckley ab7d56e0ba feat(bash): opt-in background shells with delta output reader and kill tool (#817)
Restore 'start a dev server, use it in a later call' as an explicit opt-in
after #816 made bash reap its whole process group on return. The surface
mirrors the dominant coding-agent convention: bash(run_in_background=true)
returns a bash_N handle immediately; bash_output(id, filter?) returns only
output produced since the previous read plus status and exit code;
kill_shell(id) terminates the shell's whole process group.

- Per-session BackgroundShellRegistry: capped rolling line buffer with
  drop-oldest gap accounting, exit-order record pruning, owner scoping for
  task_agents (shells reaped when the agent finishes), liveness-guarded
  group kills (a stale pgid is never signalled), budgeted teardown joins.
- Exit notices ride a shared external-event rail (sanitize, soft cap,
  channel 'any', idle wake) now common to watch fires; a new 'quiet'
  NudgeQueue channel lets a user cancel defer pending notices without
  letting them re-wake the stopped workstream, and failed wake delivery
  re-queues external notices seq- and predicate-intact without re-arming
  the wake gate.
- The bash_output filter runs in a killable subprocess: sre holds the GIL
  for an entire search, so no in-process timeout can bound a hostile
  pattern. Scrubbed child env, pinned UTF-8 pipes, honest timeout-vs-
  helper-failure error taxonomy, per-line match window with explicit
  clipping notes; a failed filter never consumes the delta.
- run_in_background rides the bash intent-judge projection; bash_output is
  exempt from the repeat warning but still recorded so interleaved polls
  keep breaking other tools' streaks; all bash boolean args share one
  lenient coercion dialect.
- Shells survive generation cancel and die with the workstream: every
  teardown path funnels through ChatSession.close(); CLI exit and the
  server lifespan now close every loaded session, signal-first and
  Ctrl-C-safe, so nothing detached outlives a graceful shutdown.
2026-07-10 15:42:33 -07:00
Patrick Buckley d5ddc95e9f fix(web_fetch): inherit model settings for the extraction completion
The URL-extraction call hard-coded max_tokens=8192 and rode the "low"
reasoning default, which broke local-inference models whose registry entry
advertises a tighter output limit or a different reasoning config. Inherit
the session/registry max_tokens and reasoning_effort instead (temperature
already was) — the same knobs the main turn uses.

max_tokens is capped to context_window // 4, the ~25% output slice Phase 2
already reserves, matching the main turn's response reserve
(_remaining_token_budget), so a large operator budget can't push
prompt + output past a small context window on strict runtimes.
2026-07-08 00:30:44 -07:00
Patrick Buckley e60c19befd fix(watch): harden nudge/wake delivery across eviction, cancel, and identity rebinds
Wake path:
- Denial metacog nudge moves to the tool channel so it drains with the
  denied tool batch instead of the next user-message seam.
- wake_workstream_if_pending: shared wake gate for watch fires on
  already-idle workstreams (no IDLE transition for the watcher to
  observe), wired as wake_fn at every set_watch_runner site via the
  shared _watch_fire_wake_fn helper (closes over the Workstream OBJECT
  — after eviction+restore an id-keyed manager lookup would miss).
- session_worker exit backstop re-runs the wake gate the moment worker
  ownership clears: IDLE fans out on the worker thread, so
  transition-time wakes always landed on the reuse path and no-op'd
  (the coordinator idle_children strand).
- deliver_wake_nudge_from_queue contains GenerationCancelled — it is
  the wake worker's run() closure and only Exception is caught
  downstream.

Watch delivery:
- Terminal fires that cannot reach their workstream are HELD and
  redelivered on min(interval, 60s) without re-running the command,
  bounded by MAX_DELIVERY_ATTEMPTS per cycle and the watch's own
  max_polls across cycles; the poll charge commits durably at hold
  time so restarts stay budget-bounded.
- Restore admission control: per-ws dedup + MAX_CONCURRENT_RESTORES
  cap, presence-only re-check under the lock, detection-only stall
  alerts (reclaiming a wedged admission would trade capped degradation
  for total poll-pool collapse).
- Permanent-vs-transient restore taxonomy: corrupt persona stamp and
  genuinely-missing history (confirmed by a raising storage probe —
  the resume loader swallows read blips into []) deactivate the watch
  immediately; everything else holds and retries.
- Cancel-race defense: delivery paths re-check is_watch_active before
  stashing/dispatching, cancel paths write the row BEFORE
  forget_terminal_dispatched, the HTTP cancel endpoint clears runner
  state, and a per-tick sweep bounds the residual stash-after-clear
  interleaving to one check_interval.
- Abandon/exhaustion commits are write-then-clear so storage that can
  read but not write retries the row write instead of re-running the
  command every cycle; the fresh-fire unrestorable path stashes before
  its deactivation write for the same reason.

Registry follows identity:
- The dispatch registry is keyed by _ws_id at registration time; every
  rebind now moves it: non-fork resume() and /new go through
  _follow_watch_registration (new key live before the old is removed,
  never stealing a registration another live session holds), removals
  are owner-checked so tearing down a watch-restore shell or a
  resumed-away session cannot unregister a live pane, the restore
  shell yields to a registration that appears mid-restore, CLI
  --resume registers after the successful resume, and both the open
  path and the detail-GET lazy rehydrate wire the registration.

Teardown gating and backpressure honesty:
- cleanup_session_ui marks ws._closed FIRST under ws._lock — every
  teardown path (close, close_idle, evict, delete, discard) funnels
  through it — and session_worker.send re-checks under the same lock,
  so a wake can never spawn a worker on a torn-down workstream.
- Create responses carry initial_message_status when the initial
  message could not be delivered (queue_full / refused_closed) instead
  of reading as success; staged attachments survive for the retry;
  /send surfaces a closed workstream as 404 rather than queue_full.

Docs/spec: OpenAPI artifacts regenerated; api-reference documents the
new create-response field; TS SDK type extended.

Tests: ~30 new pins (cancel races, budget durability across restarts,
owner-checked registry moves, teardown gating, stall alerts,
backpressure surfaces, wait_until final re-check); wide subsystem
sweep green (2353 passed).
2026-07-07 16:42:36 -07:00
Patrick Buckley 7f0e0406b3 test(approvals): concurrency matrix + suite migration to the cycle model
New regression matrix for the release blockers: cross-approval
independence, lost-wakeup at gate entry, FIFO selector-less
resolution, resolve-all sweep, double-resolution no-op, cards/legacy
view tracking, and the generation-exactness set — stale delivery
rejection, Smart-Approvals origin check, purge keep_origin, the
purge-to-register window eviction, late cross-generation "superseded"
stamping, concurrent smart+human gates, and the pre-delivered-verdict
fast path. Plus sub-agent judge wiring (agent_gate off the main
slot, close() firing all generations) and endpoint tests for cycle
pinning and the Approve+Always race guard.

Gate threads run under one shared mock-patch harness — mock.patch
start/stop of the same target from concurrent threads corrupts the
patcher's restore stack — with a sweep-until-dead teardown so the
conftest leak guard can't trip. Existing suites migrate off the
singleton fields to cycle assertions and the
pending_approval_details wire shape.
2026-07-05 01:57:54 -07:00
Patrick Buckley bcb8c5ab88 fix(skills): harden task_agent / persona / skill activation from whole-PR review
Two independent multi-agent reviews of the branch (high, then max effort) found
authority-confinement and robustness defects the per-step reviews could not
see. This commit addresses every confirmed finding. task_agent turned out to be
the surface that lagged its siblings on nearly every axis.

Risk gate (most severe):
- task_agent(skill=...) never enforced the high/critical-risk PRINCIPAL-load-
  only gate that skills(load) / spawn_workstream / spawn_batch enforce, so a
  model could route around it by delegating activation to a sub-agent. Enforce
  it inline in _prepare_task on the row already fetched (no re-query, no drift
  between get_skill_by_name and get_prompt_template_by_name).
- _high_risk_skill_denied now fails CLOSED on a storage fault: deny, never wave
  the skill through. Denying (not returning "") also keeps spawn_batch's per-row
  partial-success intact under a transient blip.
- (first round) extracted _high_risk_skill_denied onto spawn_workstream /
  spawn_batch, closing the coordinator-side bypass.

Persona confinement (Principle 7 attenuation on the task_agent edge):
- A restrictive persona now attenuates the sub-agent's TOOLS, not just its
  identity text — the tool lever is frozen into the item and filtered before
  _run_agent.
- Honor ALL FOUR persona levers on the sub-agent, not two: a child persona's
  mcp-off and memory-off levers now drop MCP tools (mcp__* + read_resource /
  use_prompt) and the memory tool, matching a main session under the persona.
- Cap the sub-agent by the PARENT session's own persona grant too, so a
  restricted principal cannot escalate authority by spawning.
- Add persona to the task_agent judge/audit func_args projection (policy +
  audit parity with spawn).
- Persona-resolution failures defer to a clean tool error (try/except mirroring
  _validate_child_persona) instead of an opaque "internal error".

Substitution / capability:
- substitute_args=False for capability contexts (defaults, task_agent) so a
  literal $ARGUMENTS / $N in a body is preserved, not blanked; env vars still
  resolve. The literal-$ARGUMENTS scan is deferred behind that guard (skipped on
  every capability render).
- Drop the CLAUDE_SKILL_DIR alias (canonical TURNSTONE_SKILL_DIR only). That
  name also lives in bash, where turnstone-as-a-node-inside-Claude-Code must not
  shadow the host's value; claiming it in the prompt but deferring in bash
  diverged the two surfaces (a review finding). turnstone now claims it in
  neither surface. The CLAUDE_SESSION_ID / CLAUDE_EFFORT prompt aliases stay
  (pure prompt values, no bash-namespace collision).

Skills-as-context:
- DEFAULT (always-on) skills stay in the identity system message — the standing
  baseline, never a mid-session cache-bust; only a NAMED applied skill moves to
  the user-role capability message. This shrinks the pending model-adherence
  eval surface to the named-skill move alone.

Cleanups: consolidate a duplicated rationale comment; correct the now-stale
"task agents are not persona-filtered" note.

PRE-MERGE GATE unchanged: the §7 Q1 model-adherence eval (named-skill move,
this branch vs main) is not runnable in-tree and must clear before merge.
2026-07-03 19:16:36 -07:00
Patrick Buckley c023272b16 refactor(skills): task_agent identity from persona, skill demoted to capability
Before, a task_agent's system identity WAS its skill (skill body concatenated
into the sub-agent's system message), and #683 deliberately gave task_agent no
persona. Now that personas are first-class on every creation/spawn path, make
task_agent consistent: identity comes from a persona, the skill is capability.

- task_agent gains persona= (validated at prep against the interactive kind,
  the general-purpose personas a worker can adopt). The resolved base prompt
  is frozen into the approval item; _exec_task never re-reads storage.
- Default identity (no persona=) stays _TASK_DEFAULT_IDENTITY. The one-shot,
  tool-over-narration operating guidance always layers on top.
- skill= is now CAPABILITY: rendered through the shared pipeline (step 1) and
  delivered as a distinct user-role context turn ahead of the task, never
  fused into the identity. Consecutive user turns coalesce at the provider
  boundary (Anthropic _merge_consecutive), so this is wire-safe.

Updates the task_agent tool schema (persona param; skill reframed as
capability) and flips the persona guard test (task_agent HAS a persona param
now). Sub-agent skill-resource materialization and the interactive
skills->context move remain follow-ups.
2026-07-03 19:16:36 -07:00
Patrick Buckley b0a5fa6856 fix(judge): give the intent judge the full tool arguments
The func_args projection in _evaluate_intent is the intent judge's
entire view of a pending call's arguments, yet it lowered only a
narrow field per tool: edit_file reached the judge as {path} with the
edits stripped, and skills mutations built their projection and never
assigned it, so the judge ruled on {}. A small local judge denied a
legitimate multi-edit edit_file at 95% confidence as "malformed,
missing old_string/new_string" on exactly this gap.

Project the full risk-relevant surface per tool — edits, file content,
timeouts, model overrides, skill risk fields, task status and
ordering, MCP resource URIs and prompt arguments — and add the
read_resource / use_prompt branches that previously fell through to an
empty {}.

Truncation is now a backstop, not a default. Arguments lower whole up
to the judge model's real context window, sourced from the registry's
per-model config rather than the static capability table (which
reports 200k for every local model and would over-budget a small local
judge into overflow). Only a genuine overflow truncates, with an
explicit dropped-character marker; the untruncated arguments always
remain in the trajectory. The verdict's persisted and streamed copy
carries a separate 16 KB backstop against pathological payloads.

A parametrized guard test asserts every gated tool projects a
non-empty argument view, so the silent-starvation failure mode fails
CI instead of shipping.
2026-07-02 22:00:19 -07:00
Patrick Buckley 73e7972fb8 fix(session): PR feedback + CI typecheck/test failures
- typecheck: dropping _acting_user_id from the SessionUI protocol (it made
  the attribute required, breaking NullUI's structural match and making
  TerminalUI abstract). _emit_state now narrows to SessionUIBase before
  assigning — the field belongs to the web-fanout UIs, not the protocol
  contract that CLI/eval UIs also satisfy.
- test: two mock queue_message stubs (attachments-endpoint fake,
  coordinator adapter double already fixed) needed the new
  interjector_user_id kwarg; the endpoint fake was raising TypeError ->
  queue_full. Added it and a negative test that a non-SessionUIBase UI is
  skipped by _emit_state.
- review (Copilot): _load_persisted_senders now latches _db_senders_loaded
  only if self._ws_id still matches the workstream it queried, so a
  concurrent resume() can't mark the read done for a workstream whose
  senders were never loaded.
- review (Copilot): corrected the acting-user-id comments in three places
  — it carries the owner id even single-user (the gate no-ops because it
  equals the viewer); it is empty only on unauthenticated lanes / before
  first state emit.

Note: the storage-protocol '...' stub flagged by the code-quality bot is
the file's universal convention (262 stubs, zero NotImplementedError);
left as-is for consistency.
2026-07-02 19:23:04 -07:00
Patrick Buckley 9c1b76b632 feat(webui): disable send for non-acting participants while busy
The UX complement to the server-side cross-user interjection block: on a
shared workstream, while another participant's turn is in flight, this
viewer's send button is disabled so they don't click into a 409 (and
can't drive tools under the initiator's credentials).

Backend signal (the linchpin — the acting user was tracked but never
surfaced to clients):
- ChatSession._emit_state pushes the acting user (turn initiator, owner
  fallback) onto its UI (_acting_user_id on SessionUIBase).
- server.WebUI.on_state_change includes acting_user_id in the broadcast
  state_change event; the mid-turn-connect replay (session_routes) adds
  it too, so a client joining mid-turn learns who holds it. Id only (a
  uuid the client compares) — no name, no storage lookup on the hot path.

Frontend:
- auth.js retains the opaque user_id from /whoami (ts.user_id) — kept
  separate from the display username, used only for id comparison.
- composer.js gains an independent hard-block axis (setSendBlocked /
  _reconcileDisabled) so send can be disabled even in queueWhileBusy mode.
- interactive.js tracks the acting user from state_change, blocks send
  when busy AND acting_user_id !== the viewer's own id, and handles the
  409 as a clean message (reactive fallback for the click-beats-event
  race) instead of a generic connection error.

Degrades gracefully: single-user workstreams (acting user == viewer) and
older backends (no acting_user_id) never engage the gate.
2026-07-02 19:23:04 -07:00
Patrick Buckley 2ba54266c6 feat(session): block cross-user mid-turn interjections
A mid-turn interjection folds into the current turn under the
initiator's identity — bind_acting_user deliberately does not rebind
mid-turn — so on a shared workstream a second participant's queued text
would run any tools it triggers under the initiator's MCP (oauth_user)
credentials (confused deputy) and be stamped with the initiator's sender
label (misattribution). Rather than fold it in, reject: queue_message
now takes the authenticated interjector_user_id and raises
CrossUserInterjectionError when it differs from the current acting user.
The send route surfaces it as 409 cross_user_interjection. Only an
authenticated non-acting participant is blocked — self-interjection,
single-user workstreams, and unauthenticated internal lanes (empty id,
e.g. the coordinator adapter) are unaffected.
2026-07-02 19:23:04 -07:00
Patrick Buckley c6e5794125 fix(compaction): recover from context overflow on resume across providers
A session created under the openai-compatible provider and resumed under the
anthropic-compatible provider (same vLLM model) failed with an opaque
InternalError instead of recovering. Root cause: vLLM returns a context-window
overflow as HTTP 400 BadRequestError on /v1/chat/completions but HTTP 500
InternalServerError on /v1/messages, and the rehydrated resume payload overflowed
the window. The 500 was retried four times then surfaced as a bare class name.

- Detect overflow by message text, not exception class (_is_ctx_overflow),
  shared across the fatal-error formatter, both stream-retry gates, the send-loop
  recovery, the chunker, and the task_agent loop. Overflow is non-retryable
  (deterministic; no backoff). Phrasing is overflow-specific so a token-quota
  rate-limit isn't misclassified.
- Proactive pre-send compaction (Layer A): when already over the hard ceiling,
  compact once before the first stream so a resume that arrives over-window (or
  follows a switch to a smaller-context model, with no prior compaction) doesn't
  go out blind. Generation-guarded end to end so an orphaned or superseded send
  can never swap the live generation's history.
- Binary-subdivision chunker: an over-window summary batch is split in half and
  the partials merged (~log2(N) calls, not one per block); a lone over-window
  block is truncated progressively down to a floor before bailing irreducible.
- Cooperative cancellation honored through compaction; send() consumes its own
  generation's cancel signal on exit, so a stale cancel can't block a later
  idle /compact and a live cancel is never disarmed.
- _format_backend_error surfaces "Context window exceeded ..." instead of an
  opaque InternalServerError, and only for unrecognized classes.
- retry/rewind, the continuation hint, and title generation all exclude the
  synthetic [Conversation summary] turn so they can't target the label.
- task_agent salvages a sub-agent's partial work on any terminal error (not only
  overflow), re-raising only when there is nothing to salvage.
2026-06-30 03:47:12 -07:00
Patrick Buckley 7f1329d3b0 fix(memory): atomic single-statement upsert for memory save/update (#735)
* fix(memory): atomic single-statement upsert for memory save/update

save_structured_memory used "try INSERT -> catch IntegrityError ->
SELECT + UPDATE". On PostgreSQL a model saving the same key twice in a
turn logged a uq_smem_name_scope violation on the failing INSERT, and the
pattern threw + caught an exception on every update.

Replace it with one statement: a new StorageBackend.upsert_structured_memory
on both backends emitting INSERT ... ON CONFLICT (name, scope, scope_id)
DO UPDATE ... RETURNING.  It returns (row, was_update) -- the full saved
row and whether an existing row was updated -- like Django's
update_or_create; was_update is the supplied (fresh) memory_id differing
from the returned id.  save_structured_memory is a thin wrapper over it.

description / mem_type of None mean "leave unset": the column default
applies on insert and the stored value is kept on conflict; an explicit
value (including "" / "general") overwrites -- so clearing a description or
setting type back to "general" now persists, where the prior
"if mem_type != 'general'" / "if description" semantics silently dropped it.
The memory tool and the memories HTTP endpoint pass None for omitted fields
and read effective type/scope from the returned row; the HTTP endpoint
returns that row directly (one query, no follow-up SELECT).

Removes the now-unused update_structured_memory primitive and its dead
STRUCTURED_MEMORY_MUTABLE constant.  Adds cross-backend storage tests and a
session tool-path test (preserve-on-omit / overwrite-on-explicit), run on
PostgreSQL via --storage-backend -- the save-over-existing path was
previously SQLite-only.

* docs(memory): clarify upsert was_update precondition

Lead the upsert_structured_memory docstring with the behavioral contract
(callers MUST supply a fresh unique memory_id) rather than the internal
id-comparison mechanism, so a future caller can't reuse an existing id and
silently get was_update=False on a real update.
2026-06-28 20:24:20 -07:00
Patrick Buckley de60127c45 fix(memory): don't recompose system prefix on memory write
Injected memories ride in the cached system block, so calling
_init_system_messages() on every memory save/update rebuilt the prompt
prefix and busted the provider prompt cache (a full system + history
re-write) -- for a memory the model already holds via the tool result.

memory(save) now only invalidates the per-turn search cache, so an
in-turn memory(search)/(list) still reflects the write; the new memory
folds into the prefix at the next natural recompose or the next session.

Also drop the redundant _init_system_messages() in the /reason handler:
reasoning effort rides in request kwargs (output_config / thinking), not
the composed prompt, so it recomposed to byte-identical output.

Add a chain-level test through the real _exec_memory -> no-recompose path
(asserts prefix unchanged, search cache invalidated, next recompose folds
the memory in). The prior memory tests either drove _init_system_messages
directly or patched it out, so this path was uncovered.
2026-06-28 18:31:11 -07:00
Patrick Buckley 8dd356b7e6 fix(task-agent): keep sub-tool steps nested + preserve denial reasons
Address the Copilot review on #732 plus a task-agent sub-tool nesting
race surfaced alongside it.

Nesting (web UI):
- A sub-tool step whose task_agent row hasn't painted yet (the 4-wide
  tool pool's ordering window) buffers and nests when the row lands,
  instead of escaping to a top-level row that looks main-harness-issued.
- A row that never paints (id-correlation mismatch / aborted agent)
  escapes its buffered steps back to a visible top-level paint after a
  grace window, so steps are never buffered invisibly or leaked.
- The nested card survives the parent row's pending->resolved rebuild; a
  call_id reused across turns builds a fresh card rather than stealing the
  prior agent's steps.
- tool_info routes through the same nesting path (no duplicate top-level
  row); a namespaced sub-tool result no longer grafts onto an unrelated
  top-level row.

Denial reasons (backend):
- Preserve the specific denial reason a gate already stamped (operator
  feedback, or the matched policy pattern; web and CLI contracts) instead
  of clobbering it with a flat "Denied by user" -- in both the sub-agent
  and the main tool loop.

Verified with the livepass task_agent harness (race + orphan-escape
scenarios, headless) and unit tests.
2026-06-28 04:09:30 -07:00
Patrick Buckley 77cb76c006 feat(task-agent): recall sub-trajectory + per-agent read isolation
Final chunk of the task_agent modernization: rebuild a finished task
agent's card from /history (reload / reopen while the workstream is in
memory) and isolate each sub-agent's file-read tracking.

Recall: _project_agent_steps projects a sub-agent's trajectory into step
items (FIFO-per-call_id pairing via _iter_agent_tool_results, shared with
_cancel_ledger; output/arguments/count capped); _stash_agent_trajectory
keeps them on the UI in an LRU-bounded store; make_history_handler
attaches them as agent_steps to each task_agent tool_call, and
replayHistory/_replayAgentCard rebuild the collapsed card. In-memory only
(durable persistence deferred); a cold/evicted entry renders the flat
parent row ("not retained"), never a fabricated 0-step card.

Read isolation: _read_files (the blind-overwrite guard's memory) is now
per-sub-agent via the _active_read_files contextvar -- _exec_task copies
the parent's set on spawn and merges the agent's reads back on
completion, so a sibling in the 4-wide pool can't suppress another
agent's guard.

Also: _exec_task now self-reports the task_agent tool_result on every
path (the parent loop only reports error/denied results centrally) --
without it the live card never completed and a failed task recorded
is_error=False in the canonical trajectory. is_error flows from
_tool_error_flags to the recalled step; on_info suppression is per-thread
so a parallel sibling tool's progress isn't dropped.
2026-06-28 04:09:30 -07:00
Patrick Buckley ca7958329a feat(task-agent): nest sub-tool steps in an expandable card
Route a task agent's sub-tool events (tool_pending / approve_request,
tagged with parent_call_id) into a collapsible card under the task_agent
row, replacing the blue on_info turn-legs.

- conversation.js / interactive.js: buildAgentCardBody +
  _routeAgentItems / _ensureAgentCard nest steps by parent_call_id.
  Collapsed by default (a task agent can run 100+ steps and the parent
  fans out many in parallel); the label carries the live count + state.
  Auto-expand when a nested approval is pending so the blocking prompt
  can't hide behind the toggle.
- session.py / session_ui_base.py: on_agent_step paints auto-tool step
  rows; namespace child call_ids by parent so the 4-wide task pool can't
  collide on local sequential ids (call_0); suppress sub-agent on_info on
  the web pane (no call_id to nest by — the card carries steps + result).
- cli.py: on_agent_step prints a dim step leg (no card on the CLI, which
  keeps its on_info).
- livepass.py: task-agent card harness driving the real InteractivePane.
2026-06-28 04:09:30 -07:00
Patrick Buckley 65eaacb341 feat(task-agent): Turn-IR sub-harness + parent-tagged step events
Rebuild the task_agent sub-harness on the canonical Turn trajectory (build list[Turn], lower via dicts_from_turns at the wire boundary) instead of hand-rolled OpenAI dicts; the cancel-ledger helpers read Turns.

Tag each sub-tool's events with parent_call_id via a lock-guarded child registry stamped centrally in SessionUIBase._enqueue, so a later UI can nest a task agent's steps under its card. Getattr-guarded on the session side so CLI/eval/test UIs are unaffected.

Behaviour-preserving (same wire shape, same cancellation semantics); the parent tag is wire-invisible and unconsumed until the frontend card lands.
2026-06-28 04:09:30 -07:00
Patrick Buckley b1542ad62d fix(title): reliable titles on thinking models; defer utility temperature
Auto-title generation and manual refresh stopped producing titles on
reasoning models (the cluster serves qwen3.6). The title call capped
max_tokens at 200, so the model's think pass consumed the whole budget and
content came back empty (finish_reason=length) -> the title was skipped.
Both paths share _generate_title, so both broke.

Title path:
- Raise the title completion to 2048 tokens so reasoning finishes and the
  title text actually lands.
- Recover the title from content (never reasoning): reuse the canonical
  _strip_reasoning (handles <think>/<reasoning>, paired or unclosed) plus a
  backstop for the opener-absent </think> shape some templates emit, take
  the first non-empty line, and peel a "Title:" label and wrapping
  markdown/quote decoration. Internal punctuation is preserved. Cap at 80 to
  match the manual-alias bound.

Temperature:
- _utility_completion no longer hard-codes a temperature; it defaults to the
  session/registry value the main turn uses. Title (was 0.7/0.3), web-fetch
  extraction (was 0.2), and compaction all defer. Hard-coding a constant
  fought thinking/no-temp models and silently overrode an explicit [models.*]
  temperature; the provider still gates temperature per model.

Tests: title sanitization across think/reasoning variants, truncation, and a
trailing-prose case; utility-completion temperature deferral + explicit
override.
2026-06-27 01:21:56 -07:00
Patrick Buckley a318265946 fix(fence): bracket trust-fence markers instead of angle-bracket XML
Swap the trust-fence marker shape from <tag_nonce>...</tag_nonce> to
[start tag_nonce]...[end tag_nonce] for both the operator fold (system-reminder)
and the output-guard judge (tool_output). Angle-bracket markup pushed some local
models out of distribution and toward emitting their own turn-structure tokens:
chat templates built around rigid <...>-style structural tokens derail once a
few folded reminders accumulate. The start/end keywords carry no slash (no </ or
[/ closing-tag shape) and read as ordinary text.

Single-source the shape in fence.py (_OPEN_KW/_CLOSE_KW + detection_pattern) so
wrap, neutralize, the forgery/leak detector, and both trust declarations track
one definition. The nonce still rides both boundaries (unforgeable close); the
leak-vs-forgery split and the forge-in / break-out defang are preserved. The
fold is wire-only, so there is no migration; the legacy persisted-envelope
readers keep the old shape.

Add regression tests pinning each trust declaration to fence.wrap's emission so
a future keyword change fails loudly instead of silently desyncing the anchors.
2026-06-26 19:37:01 -07:00
Patrick Buckley 2169559d6e feat(projects): governed project containers — memory scope, grouping, manage UI (#724)
* feat(projects): governed project containers — memory scope, grouping, manage UI

A workstream can attach to a project: a first-class, shareable resource
container that owns a `project` memory scope, groups conversations, and is
managed from the console.

Storage / migration 062: projects + project_members tables, workstreams.
project_id, and the memory type default project→general; grants
project.{create,read,write,delete} (admin-default).

Recall + writes: project memory is recalled iff the workstream is attached AND
the user has access (owner ∨ member ∨ public-for-read), resolved once at session
construction; coordinators recall it too. New saves default to the project when
attached + writable; the save and delete paths are write-gated; deleting a
project purges its scoped memory; archived projects aren't recalled.

Access = RBAC capability ∧ per-project ACL (auth.resolve_project_access, a
single-fetch resolver); visibility changes, member management, and delete are
owner-only.

API: project CRUD routes on both the server and console; project_id threaded
through workstream creation, spawn inheritance, the cluster-create proxy, the
dashboard / snapshot / coordinator row builders, and the collector deltas.

UI: a project picker with an inline "+ New project" creator in every creation
box (console launcher + standalone dialog + dashboard); group-by-project in the
rail; a project badge in the composer and on dashboard rows; a console manage
tab (list + create/edit + members shelves). The admin Memories view gains
coordinator/project scope filters and human scope labels (name, not hex). The
memory tool schema documents the project scope and the attach-aware default.

* fix(projects): client refresh hardening, creator race guard, SDK project_id

Addresses PR #724 review feedback plus two bugs found while validating it.

- projects.js refreshProjects: a non-OK status (e.g. 403 when the caller
  lacks project.read) or a network/parse error no longer blanks the cache
  or masquerades as "no projects" -- the prior cache is preserved, the
  failure is recorded (new projectsError()) and warned. Honors the
  long-standing "a transient error can't blank the rail" docstring.
- projects.js _fp: the fingerprint separators were raw control bytes,
  which made git treat the whole file as binary (no reviewable diff).
  Rewritten as escape sequences instead of raw bytes -- behavior is
  byte-identical at runtime.
- project_creator.js: createProject() could reject unhandled (authFetch
  throws on network/401; r.json() throws on a non-JSON body), leaving the
  widget stuck busy/disabled. Added a .catch, plus a generation guard so a
  create whose widget was cancelled/reopened mid-flight drops its result
  instead of selecting a project the user backed out of.
- types.ts: add project_id to CreateWorkstreamRequest / WorkstreamInfo /
  DashboardWorkstream to match the server schemas (was SDK-invisible).
- test_project_api.py: move side-effecting HTTP calls out of asserts so
  the requests run even under python -O.

* fix(projects): JSON.stringify the cache fingerprint, drop control-byte separators

_fp joined fields/rows on raw NUL/SOH bytes, which made projects.js read as binary to git. Replace with a collision-proof, escape-free JSON.stringify encoding -- same change-detection semantics, zero embedded control characters.
2026-06-26 17:24:06 -07:00
Patrick Buckley 1860d14a65 fix(coordinator): persist + eagerly generate workstream titles
Coordinator workstream LLM titles were written to workstreams.title but
never read back, and were rarely generated in the first place:

- Read path: the dashboard's `_coordinator_rows` builder hardcoded
  title="" and used the synthetic `ws.name`, so a generated title (or a
  user alias) reverted to `ws-xxxx` on every refresh. Interactive rows
  resolve via get_workstream_display_name, so the gap was coord-only.
- Write path: the auto-title trigger only fired on a tool-call-free
  assistant turn, which coordinators (near-constant tool use) seldom
  reach — so the title almost never generated.

Read path:
- Project `title` + `alias` in list_workstreams (appended after user_id so
  existing positional fallbacks stay valid). `_coordinator_rows` resolves
  the display name (alias > title > name) for both lanes — live names via
  the bulk get_workstream_display_names (exact ids, no row cap), persisted
  rows from their own _mapping.
- Seed the console pseudo-node fan-out with the resolved display name so a
  rehydrated coordinator shows its title in the live tree immediately
  (one bulk lookup instead of an N+1 over mgr.list_all()).

Write path:
- Fire auto-title right after the user turn is recorded in send(), gated on
  a real (non-wake, non-empty) user message, instead of waiting for the
  terminal tool-call-free turn. Applies to interactive + coordinator.
- Snapshot self.messages in _generate_title since it can now run
  concurrently with the streaming turn.
2026-06-17 16:03:59 -07:00
Patrick Buckley 30b590fb25 feat(memory): durable per-user coordinator scope + anonymous-coordinator guard
The coordinator memory scope was keyed by the session's ws_id, so every
new coordinator session started with an empty namespace and its rows
were orphaned on close — coordinator memory never actually persisted.
Re-key the scope to the coordinator's creator user_id: one durable
orchestration namespace per user, shared by all of that user's
coordinator sessions (concurrent ones included; upsert-by-name is the
collision rule).

The child-containment threat model is unchanged: the gate is session
KIND — children are always interactive and share the parent's user_id,
so _validate_scope rejects them before scope resolution, and the REST
memories API still rejects the coordinator scope outright. The implicit
visibility lane now also fails closed on an empty scope_id to match the
explicit search/list lanes (the storage helpers treat a falsy scope_id
as 'no scope_id filter', which would have read every user's rows).

Anonymous coordinators are no longer constructible: ChatSession refuses
kind=COORDINATOR with an empty user_id at the constructor — the single
choke point covering create, rehydration of legacy rows (surfaced by
the open handler as a 503 with remediation text), and any future host —
and the console no longer masks an empty uid as a phantom 'system'
principal when minting coordinator JWTs, per CoordinatorTokenManager's
documented 'sub = the real creator user_id' contract.

Migration 061 carries existing coordinator rows across: rows whose
owning workstream is gone or ownerless are deleted (unreachable under
user keying), same-name collisions within a user keep the newest
updated row (memory_id tiebreak), and survivors re-key to the owner's
user_id.
2026-06-12 13:54:49 -07:00
Patrick Buckley ee799f67de fix(memory): touch access metadata on composition and tool reads
The touch_structured_memories facade and both storage backends were
implemented but had zero call sites, so access_count never moved and
last_accessed never advanced past write time on any deployment.

Wire two touch points:
- proactive composition touches the injected top-k (post-rerank) set,
  deduped per turn since _init_system_messages recomposes many times
  within a single turn;
- the memory tool's search and get reads touch their returned rows,
  counted per call. save/delete/list do not touch.

Touches are best-effort through the facade, which already swallows
storage errors, so a failed touch never breaks composition or a tool
call.
2026-06-11 01:12:28 -07:00