Commit Graph

1388 Commits

Author SHA1 Message Date
Patrick Buckley 2d174cd71c perf(webui): bound the transcript — containment, block flow, windowing
The remaining steady-state cost after the wedge-proofing pass was
structural: every row of an unbounded transcript participated in every
layout, and full re-renders rebuilt all of it.

CSS pair (shared scroller + the ui/static duplicate):
- The messages scroller is BLOCK flow, not a column flexbox — flex
  relayouts all items when the streaming row's height changes, O(rows)
  per token; block flow dirties only the tail. The old per-child
  flex-shrink pin (and the min-height:auto squish hazard it suppressed)
  goes with it; inter-row rhythm moves to a sibling margin.
- overflow-anchor: none — the pane owns bottom pinning, and native
  anchoring kept re-selecting an anchor inside the innerHTML-replaced
  live bubble every frame.
- content-visibility: auto with contain-intrinsic-size: auto estimates
  on .msg (80px) and .conv-batch (200px) rows, exempting the last two
  children so the live tail never toggles skip-state mid-stream. The
  `auto` keyword memoizes each row's rendered size, keeping scrollHeight
  and the bottom pin stable once painted.

Transcript windowing (interactive pane):
- Full re-renders paint the most recent 300 messages, cut FORWARD to a
  user-turn boundary so an assistant tool_calls message is never split
  from the tool results that anchor to it. Hidden content sits behind a
  "Load earlier messages" pager; each click grows the window a step and
  refetches, restoring the scroll anchor by scrollHeight delta (the
  rAF pin re-checks the near-bottom flag at fire time, so no
  suppression is needed). Rewind/edit turn math is tail-relative and
  unaffected — pinned as such.
- Live appends are bounded at the idle edge: past 900 rendered rows the
  oldest rows are trimmed (again to a turn boundary), only while pinned
  to the bottom — a scrolled-up user is reading the rows a trim would
  remove. Trimmed content stays in /history and returns through the
  pager; detached agent-card entries are swept.

The perf page gains ?window= (and the runner --perf-extra) so windowing
and containment effects can be isolated.

Measured (n=3000 history + 20-turn storm; baseline -> previous branch
-> this change): full replay 1060ms -> 238ms -> 28ms windowed / 107ms
with the window grown to the full transcript; longtasks during the run
6/1080ms -> 4/495ms -> none windowed / 4/205ms unwindowed; per-turn
live-storm cost at n=3000 now equals n=300 (~220ms harness floor) even
with all 25k nodes live — the transcript-size tax is gone. Known
degraded-mode cost: the chunk path at a fully-grown window measures
~1017ms vs the 833ms floor; the shipped windowed config sits at the
floor.
2026-07-02 00:33:35 -07:00
Patrick Buckley df573b7314 fix(webui): address review feedback on roster eviction and dead guards
- applyRosterSnapshot: null-prototype membership map (a ws id colliding
  with an Object.prototype property name would read as always-seen and
  dodge eviction) and a stable Object.keys snapshot for the eviction
  walk — current-key deletion during for...in is spec-safe, but the
  snapshot is self-evidently order-safe and skips inherited keys.
- _streamingRenderApply: drop the tautological typeof guards around the
  post-render decorators — both are module-local declarations, and the
  surrounding try/catch owns decoration fault tolerance.
2026-07-02 00:32:11 -07:00
Patrick Buckley 3c7a3c1375 fix(webui): wedge-proof the live-session pipeline and de-O(N) hot paths
Long sessions (5000+ messages, several compactions) degraded steadily
and could stop rendering entirely while the backend stayed healthy.
Four hard failure mechanisms, each sufficient on its own:

- Unguarded event pipeline: one throw escaping onmessage/handleEvent
  (e.g. renderMarkdown stack overflow on a few KB of nested "> ")
  stranded the streaming refs, so every later delta painted into the
  poisoned segment. stream_end now resets segment refs BEFORE the
  finalize render with a plain-text fallback (the coordinator pane's
  existing pattern); onmessage guards both parse and dispatch;
  renderMarkdown is depth-capped with throw-safe footnote-scope
  accounting; the streaming buffer is marked rendered only on success.

- Rebuild-vs-live races: clear_ui/replay_truncated re-renders wiped
  events painted in the snapshot->replaceChildren window (never
  redelivered) and left deltas writing into detached nodes. Rebuilds
  now quiesce the event stream behind a token-owned queue flushed
  after the render; streaming refs reset on every rebuild path
  including refetch FAILURE; a mid-stream replay_truncated defers its
  re-sync to the idle edge instead of dropping the repair.

- Ignored recovery floor: the global stream now handles node_snapshot
  and replay_truncated. Roster eviction (with a "Session ended" toast
  for open panes) happens only from the stream-ordered snapshot; the
  REST resync is merge-only and r.ok-gated so a mid-restart 503 body
  cannot read as an authoritative empty roster.

- Unbounded growth: _agentCards released on rebuild — deliberately NOT
  on transport-only reconnects, which must preserve the maps or the
  next child event builds a duplicate card; orphan grace timers
  cancelled on full reload/destroy; toast queue capped with duplicate
  coalescing; diff previews capped at 400 rendered lines (the
  spread-append could throw RangeError before the approval gate
  painted) with the omission notice below the scroll box; raw results
  clamped at 64KiB.

Per-event O(N) work removed from the hot paths: thinking-indicator
instance ref; near-bottom cached from a passive scroll listener and
re-checked at rAF pin time (a user scroll-up landing in the coalescing
window wins; ResizeObserver re-engages follow after layout changes);
rAF-coalesced outer and per-stream scroll pins; self-healing
call_id->row/stream lookup caches; verdict lookup scoped to the row's
batch; tracked retry holder; queue-controller Set replaces the
whole-transcript idle sweep; rail renders rAF-coalesced; coordinator
child_ws_state ticks routed to single-row updates (full render only on
terminal-boundary crossings) with observer unobserve on replace.

Also: the coordinator SSE-error 401 probe is un-deadened (raw fetch —
authFetch never resolves a 401 — with the body inspected so a
version_mismatch still takes auth.js's upgrade-reload path via the new
noteVersionMismatch export); the console cluster-SSE reconnect timer
is tracked across logout; the mermaid render chain is rejection-proof
per link and paints errors on the containers the failing link had
already claimed.

Measured with scripts/livepass.py --perf (n=3000 history + 20-turn
live storm): full replay 1060ms -> 238ms; re-render cycles 836-1071ms
-> ~94ms flat; chunk path now flat vs transcript size; worst longtask
1080ms -> ~500ms; agent-card retention across rebuilds 4 -> 0.

Known limit (needs a server-side event watermark on /history): a turn
completing inside the refetch window can paint twice after the quiesce
flush — rare, visible, and strictly better than the silent loss it
replaces.
2026-07-02 00:32:11 -07:00
Patrick Buckley 41e7d5b7d7 feat(livepass): add long-session perf harness (--perf)
New /perf/livepass.html mounts the real InteractivePane at production
scroll geometry and drives production-shaped SSE events through
handleEvent/replayHistory in real time (no virtual-time budget, no
forced reduced-motion — both corrupt the measurement), reporting:
replayHistory wall time at N messages, per-turn live-storm cost on top
of that transcript, tool_output_chunk throughput, busy/idle churn,
heap + node + agent-card counts across repeated replay cycles (the
detached-DOM leak probe), and longtask counts.

The --perf runner builds, serves, and launches headless Chrome with
--js-flags=--expose-gc and --enable-precise-memory-info so heap
numbers are real floors; the page POSTs its JSON report to
/perf/report. Reports carry a per-attempt run token the runner
validates, so a straggler POST from a killed prior attempt cannot be
misattributed to the next size, and the wait loop polls the Chrome
process so a sandbox startup failure bails to the --no-sandbox
fallback in seconds instead of burning the full timeout.
2026-07-02 00:32:11 -07:00
Patrick Buckley ca23f2876c fix(ci): refuse fork PRs in the vendor-js dispatch path
The workflow_dispatch input is an arbitrary PR number, and the job used
only headRefName to pick the checkout ref. For a fork PR that is a bare
branch name that can collide with a branch in this repo, so the job
(contents:write, ends in git push) would operate on that unrelated
branch. Resolve isCrossRepository alongside headRefName and fail loudly
unless the PR head lives in this repository.
2026-07-01 21:32:45 -07:00
Patrick Buckley a9898fdd6c fix(ci): gate workflow_run publishing to same-repo tag pushes
The publish and docker workflows trigger on workflow_run of CI, which
fires for every CI completion — including CI runs for pull requests
from forks — and always executes with this repo's secrets, tokens, and
the pypi environment. The only gate was CI success, so fork-PR CI runs
spawned publish jobs in the upstream context; actions/checkout v7's
fork-checkout refusal was the only thing that stopped one on 2026-06-30.
A fork PR whose head is an upstream-tagged commit would have passed the
tag check and reached the upload with valid OIDC.

Both workflows now require the triggering CI run to be a push event,
from this repository, with head_branch starting with 'v' — CI's push
trigger only matches main/stable/* branches and v* tags, so that is
necessarily a tag run (verified: tag-push runs report the tag name as
head_branch). Checkouts no longer persist the token while the tree's
build backend executes, and publishes are no longer cancellable
mid-upload (a half-uploaded release cannot be re-run cleanly because
PyPI rejects duplicate files).

vendor-js hardening in the same pass: gate on the immutable PR author
instead of github.actor, require a same-repo head before pushing to the
PR branch with contents:write, and pass github.head_ref through env
instead of interpolating it into the script body.
2026-07-01 21:32:45 -07:00
Patrick Buckley c71cc749d9 chore: bump version to 1.7.0a6 v1.7.0a6 2026-07-01 21:07:52 -07:00
Patrick Buckley 2fb80cb88f fix(compaction): count fixed prompt overhead in the carry budget
Review finding on #751: the carry invariant omitted the system message
and tool definitions, which ride every request — at shipped defaults
reserve + 2 carries + margin lands exactly at the window, so any real
prompt overhead pushed the post-compaction send over it, and the
overflow backstop re-compacts WITHOUT the carries.

spare now subtracts system_tokens + tool_def_tokens (the same terms the
_estimated_prompt_tokens fallback counts), making
overhead + reserve + carries*budget + margin <= window hold by
construction. Invariant test pinned at shipped defaults with a 4k-token
synthetic prompt; a monotonicity test pins that the term is live; exact-
arithmetic tests isolate the overhead explicitly.
2026-07-01 21:06:31 -07:00
Patrick Buckley 2dd0688d45 fix(compaction): carry the plan and the ask across compaction verbatim
The definition review found the two control-relevant crossings paraphrased:
the model's wind-down spill (recorded on the cooperative advisory, then
handed to the summarizer with everything else) and the user's last message
(clipped to 400 chars in the continuation hint). Both now cross copied.

- carry_spill: when the model stopped because it was advised to wrap up,
  its final turn's text is shell-concatenated onto the summary under
  '## Wind-down (verbatim)', ahead of '## Continue'. The summarizer still
  reads the spill; its paraphrase is no longer the only survivor.
- _carry_budget_chars(carries): ~25% of the window per carry, sized so ALL
  concurrent carries fit the spare after the summary output reserve —
  spill + hint fire together at the end-of-turn site, and independent
  sizing stacked reserve + 2*(cw/4) + margin past the window at default
  config. Floored at 2000 chars; oversize content keeps head + tail.
- _truncate_block's marker reports the original size ('truncated — N chars
  total'), and a truncated carry adds one line telling the model the full
  text remains in history and recall can retrieve it.
- Summary turns carry source="compaction" (in-memory swap and checkpoint
  reconstruction); _find_turn_boundaries and _generate_title test the tag
  instead of the label string, so a user who literally types
  '[Conversation summary]' stays a real turn.
- The send-loop overflow backstop now passes my_generation, closing the
  compact-and-swap race every other compaction site already guards.

Tests: tests/test_compaction_crossing.py (tags on both paths, literal-label
boundary, budget arithmetic incl. the double-carry invariant at shipped
defaults, verbatim/truncated carries, spill semantics, forwarding); existing
suites updated for the tagged label turns and the new kwargs.
2026-07-01 21:06:31 -07:00
Patrick Buckley 848f123985 feat(recall): scope the recall tool to the compacted past
After a compaction, storage keeps the full transcript and the in-context
summary is a cache over it — recall is the model's re-derivation path back
into the originals. Un-scoped, its results duplicated the live context.

- search_history gains exclude_ws_id/exclude_after: the excluded ws's rows
  above the boundary (the live segment, already in context) are dropped in
  SQL via one shared fragment; rows at or below it — the summarized-away
  past — stay searchable. A never-compacted ws is excluded whole:
  everything is live. Other workstreams untouched.
- New get_compaction_checkpoint(ws_id) reads the latest marker's persisted
  watermark (distinct from get_compaction_watermark, which computes what a
  NEW compaction would use); the meta decoder is single-sourced with the
  resume slice (parse_checkpoint_watermark) so the two boundary consumers
  cannot drift.
- _exec_recall reads the boundary fresh at execution (a compaction that ran
  while the item was queued is respected) and labels own-conversation hits
  '(earlier in this conversation, compacted)'. Storage errors degrade to
  whole-ws exclusion — less information, never duplicates. Known limit
  (documented): a forked session excludes only its own ws, so inherited
  parent rows remain searchable — harmless duplication bounded by tenancy.
- NUDGE_COMPACTION_RESUME teaches the path: the summary is a digest, not
  the record, and recall can search the compacted portion.
- /history deliberately unchanged: a human browsing history has no context
  to duplicate.

Tests: tests/test_recall_compaction_scope.py — checkpoint reads (none /
marker / latest-wins / malformed-as-live), the exclusion matrix, the
composed tenancy+exclusion query with both filters dropping rows, exec
plumbing and labeling, the nudge line; cross-backend.
2026-07-01 21:05:57 -07:00
renovate[bot] 76241ab703 chore(deps): lock file maintenance 2026-07-01 19:47:04 -07:00
renovate[bot] 409875e296 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.11.26 2026-07-01 19:46:50 -07:00
Patrick Buckley 8e4f32c93a chore(ci): allow Renovate PRs through Claude Code review
Renovate opens PRs as a bot actor, which claude-code-action's default
human-actor check rejects — Renovate's dependency-bump PRs were never
getting reviewed.
2026-07-01 19:44:27 -07:00
Patrick Buckley 3e4c1931a1 fix(storage): scope conversation-history search by project tenancy
search_history / search_history_recent searched every workstream's rows
regardless of who asked. Pre-projects that matched the trusted-team
deployment shape; with private projects (062) it became a cross-tenant
read — the recall tool and /history returned private-project rows to
non-members.

Both methods take a keyword-only user_id (protocol, sqlite, postgresql)
scoped by one portable SQL predicate (HISTORY_VISIBILITY_SCOPE_SQL)
mirroring WorkstreamProjectVisibility: a row hides only when its
workstream links to an existing private project and the user is neither
the workstream creator, the project owner, nor a member. Applied in SQL
so limit/offset pagination stays honest; COALESCE guards the
NULL-creator row, which plain <> would leak.

The recall tool pins the scope identity at prepare time (the mcp_user_id
discipline) and fails loudly on an unpinned item; /history scopes to the
acting user; user_id=None (single-user CLI lanes) stays unscoped.

Tests: cross-backend visibility matrix, ws_visible parity pin,
marker-exclusion composition, LIKE-fallback path, prepare-pin plumbing.
2026-07-01 19:29:20 -07:00
renovate[bot] df7926215b chore(deps): update github actions 2026-07-01 19:26:18 -07:00
Patrick Buckley f583fb06db fix(projects): address PR review feedback — drop redundant asyncio import, precise failure-mode docs, format
The redundant function-local asyncio import in project_resources_endpoint
shadowed the module-level one. resolve_workstream_owner's docstring now
maps the failure modes precisely: a failed ROW lookup is fail-soft 404
(get_workstream_row degrades to None, pre-existing behaviour), while the
fail-closed 403 applies once a row is resolved and the project gate's
storage lookup fails — in-memory workstreams 403 on a gate blip,
not-loaded ones 404 at the row fetch first. Plus ruff-format on the
visibility test file (edited via script, so the local format hook never
saw it).
2026-07-01 18:01:50 -07:00
Patrick Buckley 4bca60c56c fix(projects): close review-found tenancy leaks + correctness regressions
Max-effort review findings on the visibility feature, worst first:

Leaks — the filter was sound where it ran, but several surfaces never
carried project_id to gate on:
- cluster_snapshot served the raw collector state with no filter at all;
  it now gets the same per-request tenancy treatment as its siblings
- console pseudo-node coordinator rows + emit_console_ws_created,
  the interactive-create ws_created event, and the poll-diff ws_created
  now carry project_id/user_id (parity with their filtered siblings —
  a missing field failed open, and a missing user_id over-hid the
  creator's own workstreams)
- the SSE snapshot's overview total/state histogram is re-derived from
  the filtered rows instead of leaking pre-filter counts

Correctness:
- saved list pages with OFFSET until it fills its 50-row window instead
  of filtering after the LIMIT (a caller's own rows at position 51+
  used to vanish behind other tenants' private rows); scan capped at 20
  pages, logged when hit
- an INHERITED project_id whose project was since deleted no longer
  400s coordinator child spawns — the dangling link is dropped; explicit
  unknown ids still 400, revoked membership still 403s
- the SSE filter keeps a per-connection unresolved map: a storage blip
  suppresses a row without pinning it hidden until reconnect (re-judged
  on later events, rate-limited); definitive verdicts settle as before
- bypass principals (service / admin.cluster.inspect) get payloads
  untouched — no row drops, no overview rewrite

Consistency and robustness:
- dashboard + saved-list visibility checks moved off the event loop
  (executor), matching every sibling site
- list_project_attachments chunks its IN() at 500 ids per statement
- ws_visible/ensure_project_attachable now share one _project_grants
  predicate so the tenancy rule can't diverge
- resolve_workstream_owner's docstring states the deliberate
  fail-closed trade for project-attached rows during DB outages
- the workstreams-for-project ordering test asserts strict order on a
  forced timestamp instead of a vacuous set fallback
2026-07-01 18:01:50 -07:00
Patrick Buckley 80b8997b88 fix(projects): full-suite findings — type-guard the visibility gate, bind acting user without breaking send stubs
ws_visible only treats real strings as project links (a test double or
corrupted value means no-project, not private-and-denied), the mgr-path
project_id is coerced likewise, and the HTTP send path binds the acting
user via a getattr-guarded bind_acting_user call inside the fresh-turn
closure instead of a send() kwarg — per-kind session stubs with explicit
send signatures keep working. Row-shape contract tests (interactive +
coordinator twins) grow the intentional project_id key.
2026-07-01 18:01:50 -07:00
Patrick Buckley bf9299de1a feat(projects): saved-list project column + per-project resources view
Dashboard saved-sessions lists now carry and render the workstream's
project: SavedWorkstreamInfo gains project_id (the saved projection was
extended in the visibility change), SavedColumns grows a PROJECT column
(name resolved through the shared projects data layer, searchable via
the filter haystack, re-rendered when the async project cache fills),
inserted on both the webui saved-workstreams and console saved-sessions
tables.

Manage → governance → Projects rows are now expandable (same
interaction contract as the Users tab's OIDC panel): a per-project
resources panel lists the project's workstreams (kind/state/updated),
referenced attachments (metadata + ws-scoped download link through the
console's node proxy), and the project-scoped memory count. Backed by
GET /v1/api/projects/{id}/resources (project.read + per-project ACL,
collection off the event loop) over two new storage queries —
list_workstreams_for_project (first consumer of idx_workstreams_project)
and list_project_attachments (conversation ref-list walk, metadata only,
first-referencing ws per blob, pruned blobs skipped).
2026-07-01 18:01:50 -07:00
Patrick Buckley fbfd170ca6 feat(projects): enforce private-project workstream visibility server-side
Workstreams attached to a private project were listed and reachable for
every authenticated user — only the scope tier was checked. Add a
tenancy predicate (WorkstreamProjectVisibility: private → project
owner/members, the workstream's own creator, service scope, or
admin.cluster.inspect; public/dangling/no project → unchanged
trusted-team visibility; membership itself is the grant — deliberately
NOT gated on the project.read capability, which guards the management
API) and apply it at every surface:

- listings: saved sessions (project_id + owner tail-appended to
  list_workstreams_with_history on both backends), active list, node
  dashboard, console cluster list (pre-pagination via a collector
  row_filter so totals stay honest), node detail
- console tier-1 SSE: per-connection snapshot filtering + a hidden-set
  for sparse follow-up events; ws_created project lookups run on the
  executor, membership changes take effect on reconnect
- row access: resolve_workstream_owner 403s private-project rows for
  non-members, covering every interactive ws-scoped verb via
  tenant_check (console coordinator lane stays on its privileged
  admin.coordinator gate)
- create: ensure_project_attachable gates explicit and parent-inherited
  project_id on both create validators (unknown project 400s instead of
  minting a dangling link)
2026-07-01 18:01:50 -07:00
Patrick Buckley 71c34839d9 fix(mcp): resolve oauth_user credentials for the acting user on shared workstreams
Per-user MCP credential resolution was bound once at session construction
to the persisted workstream owner, so on a shared workstream every sender
executed oauth_user tools under the creator's tokens (and saw the
creator's tool catalog). Bind the authenticated initiator of each turn
(send + retry paths) as the session's acting user: dispatch, catalog
merge, visibility gates, and consent flows now follow whoever is driving,
with the owner as fallback for CLI / eval / scheduled / internal turns.

Rebinding swaps the user-scoped tool/resource/prompt listeners (identity
is the (user_id, callback) pair), fire-and-forget primes the acting
user's pools, and rebuilds the merged tool list. Prepared tool items pin
the identity at prepare time so an item pending approval executes under
the user whose turn requested it, not whoever binds later. Queued
mid-turn interjections deliberately do not rebind (no mid-turn
credential switch).
2026-07-01 18:01:50 -07:00
Patrick Buckley f923351953 docs(hypothesis): harden the harness definition after peer review
Corrections: the middle-form re-separation names its true mechanism
(restarting specs or refusal-event predicates; within-run retries never
touch F), the standard-Borel aside admits belief-state coordinates, the
drift-slack display binds its variable, effect-record status gains a
`none` value (never launched) distinct from rolled_back and unknown,
and parsing is assigned to the inner readout R with the gate as pure
authorization.

Structure: the trusted principal as the provenance lattice's single
widening writer; two-rank control (authority vs plan) with a
rank-neutrality corollary; the narrow-only rule for learned checks;
pi's never-lower filter joins the deterministic core; gate TOCTOU and
cross-run serialization; a composition law for harness trees (four
correspondences) with delegation as monotone attenuation.

Appendix: new worked entries for resume (journal-before-dispatch),
parallel proposals (the batch gate), derived and durable state (the
provenance meet rule), and ambient authority (per-action capability).
Claims numbered C1-C8; two falsifiers added (certificate compression;
working-set probe anchored in streaming lower bounds).

Grounding: adds Ramadge-Wonham supervisory control, RL shielding, and
Dayan's successor representation; repairs the Positivity/Skolem gloss
and two citation characterizations. All 18 external citations verified
against their sources.
2026-07-01 18:01:07 -07:00
Patrick Buckley a7cab83dd1 fix(mcp): address pre-push review findings
A max-effort review of the branch before pushing surfaced six defects, several
introduced by this branch's own commits. All fixed:

[0]+[3] oauth priming (refined). Fully non-destructive priming never cleared a
genuinely-revoked grant — the dead token stayed "consented", its tools never
entered the catalog, and (bug) the PERMANENT branch returned before arming the
cooldown, so every session re-hit the AS with a dead refresh token. Root cause:
invalid_grant (PERMANENT) is a RELIABLE dead-grant signal (RFC 6749 §5.2), so
deferring its revoke was net-harmful. Renamed the flag revoke_on_dead_grant ->
revoke_ambiguous_escalation: priming now revokes genuinely-dead grants (permanent
/ expired-no-refresh) so the catalog isn't stranded cold behind a phantom token,
and defers ONLY the sustained-UNCLASSIFIABLE (ambiguous) escalation to lazy
dispatch — the case the "don't revoke an unused server's grant on a
misclassification" concern actually applies to. The cooldown is armed before the
ambiguous path, so the deferred case can't hammer the AS either.

[1] server.py. _public_server_status (operator refresh/reconnect endpoints)
didn't forward the new scope, so after per-user scoping every warm oauth_user
server rendered disconnected/empty there. Now passes aggregate=True (operator /
approve-scoped cluster view, matching the admin console).

[5] _is_dead_transport. The widened httpx.TimeoutException swept in
httpx.PoolTimeout — pool saturation, NOT a dead connection — so transient load
would evict a healthy session and trip the shared breaker for all users.
Narrowed to Connect/Read/WriteTimeout (kept NetworkError, RemoteProtocolError).

[8] _is_dead_transport. The exact-message "session terminated" fallback still
fired on a healthy session-owning server's protocol error with that message. The
SDK-synthesized code 32600 is the only deterministic signal (the message is
application-controlled), so match the code ALONE and drop the message fallback.

[11] cleanup. The dead-transport except block was triplicated across
call_tool_sync / read_resource_sync / get_prompt_sync — the exact drift this
branch had to repair. Extracted _record_and_evict_on_dead_transport.

Tests updated/added: prime revokes-permanent / defers-ambiguous (drives the real
resolver both ways); PoolTimeout-is-not-dead; exact-"Session terminated"-message
stays alive; _public_server_status aggregate. 836 test_mcp_* green, ruff + mypy
clean.
2026-06-30 19:30:20 -07:00
Patrick Buckley b28e8bac80 feat(mcp): admin-scoped aggregate view for oauth_user server status
Resolves the one regression the user-scoping in 0c28b0ce introduced: the admin
console reaches the read-scoped /mcp-status endpoint via the console proxy with
the ADMIN's forwarded identity, so per-user scoping made oauth_user servers show
as the admin's own (usually empty) pool instead of the cluster-health "in use by
anyone" aggregate.

Add an `aggregate` flag (default False) through get_all_server_status ->
get_server_status -> _oauth_user_server_status. When set, connected + a
representative catalog reflect ANY user's warm pool. internal_mcp_status gates it
on the admin.mcp permission: holders (who already see consent counts + server
config — the proxy forwards permissions via create_jwt, repopulated on validate)
get the aggregate; every other read-scoped caller stays strictly per-user, so the
cross-user catalog leak stays closed. Static-server status is unaffected.

Tests: manager-level aggregate-sees-any-user, and an endpoint-level gating test
asserting admin.mcp -> aggregate=True / read+approve-without-it -> aggregate=False.
2026-06-30 19:30:20 -07:00
Patrick Buckley 48f4c41442 fix(mcp): scope oauth_user server status to the requesting user
Follow-up to f585c47b (review finding #4). _oauth_user_server_status derived
connected + tools/resources/prompts counts from warm[0] — an arbitrary user's
pool entry — and get_all_server_status surfaced that to every read-scoped
caller of /v1/api/_internal/mcp-status, ignoring who was asking. So user B saw
user A's oauth_user server as connected with A's catalog size, over the wire
(connected + the three counts are in _READ_STATUS_PUBLIC_KEYS; user_pools /
auth_type are stripped). Before f585c47b these servers were absent from the
read map entirely.

Thread user_id through get_all_server_status -> get_server_status ->
_oauth_user_server_status; the warm-pool filter now matches uid == user_id, so
connected + counts reflect ONLY the requester's own pool. internal_mcp_status
passes _auth_user_id(request); an empty/absent principal (user_id falsy) sees
oauth_user servers as not-connected. Static-server status is unaffected (the
new param defaults to None and is ignored for them).

Note: the admin console (admin.mcp) reaches this same read endpoint via the
console proxy, which forwards the ADMIN's identity — so an admin now sees an
oauth_user server scoped to their OWN pool (typically not-connected) rather
than the prior any-user aggregate. Server-global health (circuit_open / error /
consecutive_failures) is unchanged, and the consented-users-count is a separate
aggregate. Restoring an aggregate in-use pill for admins (without re-leaking
per-user catalogs) would need a privilege-aware aggregate mode + admin.js
change — deferred.

Tests: updated TestOAuthUserServerStatus to the scoped signature, added the
cross-user isolation regression (user B sees neither A's connected flag nor A's
catalog size) and a no-user-context case.
2026-06-30 19:30:20 -07:00
Patrick Buckley 7f50fbefad fix(mcp/oauth): make session-start pool priming non-destructive
Follow-up to f585c47b (review finding #5/#6). f585c47b routed
_prime_user_pools through get_user_access_token_classified to refresh expired
tokens at session start (closing the chicken-and-egg where an expired token
stranded the pool). But that resolver also REVOKES a grant (delete_user_token
+ token_revoked audit) on a permanent-classified refresh failure — and priming
runs for EVERY consented server, so a single misclassified AS hiccup (e.g.
invalid_grant during a key-rotation window) could now delete a working grant
for a server the user isn't even using this session. The _prime_one comment
still claimed "priming can never revoke a live grant" — no longer true.

Add revoke_on_dead_grant: bool = True to get_user_access_token_classified. When
False, the four would-revoke sites return refresh_failed_transient with the
token left in place instead of deleting it. _prime_one passes False: priming
still refreshes+persists refreshable tokens (f585c47b's fix intact) but never
revokes — the authoritative revoke stays on the lazy-dispatch path, where the
user actually invokes the tool and a permanent failure means re-consent anyway.

Replaces the vacuous prime test (which fully stubbed the resolver, so its
"never revoke" assertion was meaningless) with a test that drives the REAL
resolver and pins both directions: same permanent failure, same code path,
revoke_on_dead_grant=False keeps the token / =True (lazy default) deletes it.
2026-06-30 19:30:20 -07:00
Patrick Buckley b8addd55c0 fix(mcp): complete dead-transport handling + harden oauth_user status
Follow-up to f585c47b. Three correctness gaps from a max-depth review of
that commit, all in the same dead-transport / session-corpse family it set
out to close.

1. read_resource_sync and get_prompt_sync were left on the old
   BrokenPipe/ConnectionReset/EOF-only eviction guard, so a dead
   streamable-http transport (McpError(CONNECTION_CLOSED), anyio
   ClosedResourceError, server-restarted session) reused the corpse session
   forever — the exact restart-hang call_tool_sync already fixes, just for
   resources and prompts. Both now route through _is_dead_transport and
   evict + trip the breaker like the tool-call path.

2. _is_dead_transport matched a bare "session terminated"/"session not
   found" substring, so a healthy session-owning MCP server (game/shell)
   rejecting a stale id with those words was misclassified as transport
   death — evicting the live session and opening the SHARED per-server
   breaker for every user after 3 such rejections. Now anchored on the
   SDK's deterministic synthesized code (32600, pinned as a named constant)
   with its exact message as a forward-compat fallback. The client never
   receives "session not found" for a real dead transport (the SDK discards
   the server's 404 body), so the tightening loses no coverage.

3. _is_dead_transport omitted httpx's read/write/close NetworkError leaves
   and the whole TimeoutException family (Read/Write/Pool timeouts are NOT
   builtin TimeoutError), so a stream that died on an idle read timeout —
   the dominant idle-death mode — fell through to "other" and the corpse
   was reused. Broadened to httpx.NetworkError | TimeoutException |
   RemoteProtocolError (LocalProtocolError, our own bug, stays excluded).

Also: _oauth_user_server_status iterated _user_pool_entries without a
list() snapshot, so a concurrent pool insert/evict on the mcp-loop thread
could raise "dictionary changed size during iteration" and 500 the status
endpoint. Snapshot like the sibling get_all_server_status does.

Adds TestIsDeadTransport (direct classifier unit tests, incl. the
healthy-"session not found"-is-not-dead and httpx-coverage regressions) and
resource/prompt eviction tests. All 8 behavior-change tests fail on the
pre-fix source and pass with the fix.
2026-06-30 19:30:20 -07:00
pow3rtool f585c47b7d mcp dead transport fix and token refresh 2026-06-30 15:22:23 -07:00
Patrick Buckley ee3a0297ea fix(compaction): don't classify a recognized rate-limit as context overflow
_stop_retrying calls _is_ctx_overflow with no exception-class gate of its own,
so a retryable 429 whose token-quota text contains an overflow phrase (e.g.
"... maximum number of tokens allowed per minute ...") was treated as a
deterministic overflow and made non-retryable.

Gate _is_ctx_overflow on "not a known backend class": an overflow is never a
recognized error (it arrives as BadRequestError/InternalServerError, neither in
_BACKEND_KNOWN_EXC_NAMES), so excluding known classes can't suppress a real
overflow while keeping a 429 retryable across every caller (the retry gates,
send-loop recovery, chunker, task_agent loop, formatter). _format_backend_error
drops its now-redundant inline class check.

Addresses Copilot review feedback on #740.
2026-06-30 03:47:12 -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
renovate[bot] 85b62860b2 chore(deps): update actions/checkout action to v7 2026-06-30 03:46:34 -07:00
renovate[bot] 74cf4e92aa chore(deps): pin dependencies 2026-06-30 01:38:15 -07:00
Patrick Buckley 6572b53c89 docs: refine HYPOTHESIS.md harness definition
- Add a plain-terms gloss of the claim (shell/plant split up front)
- Add a 'Converged-upon' grounding subsection: independent corroboration
  from capabilities, control theory, software architecture, and LM theory
- State provenance as a precondition of the reach-avoid certificate
  (CaMeL control/data-flow separation), not just an entry point to police
- Drop redundant 'none' from the effect-record status enum; normalize
  minor notation (A_bot, h->N)
- Fix stray backslash-escaped quotes that rendered literally
2026-06-28 21:59:44 -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 5004858032 ci(claude): grant write permission so Claude reviews/replies can post
claude-code-review.yml granted pull-requests: read, so the Claude reviewer
ran green but its post step was permission-denied (permission_denials_count:
3) and posted no review on the PR. Bump to pull-requests: write so it can
post the review + inline comments.

claude.yml (the @claude responder) had the same read-only block and would
silently fail to post a reply; widen it to pull-requests + issues: write.

contents stays read -- no repo-push capability is granted. Both workflows
remain gated (the reviewer to same-repo PRs via head.repo.full_name ==
github.repository; the responder to @claude from OWNER/MEMBER/COLLABORATOR),
so write is scoped to already-trusted triggers.
2026-06-28 20:09:05 -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 c7e0358aaf Add Claude Code GitHub Workflow (#733)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-28 17:00:27 -07:00
Patrick Buckley bbadd00ac0 chore: bump version to 1.7.0a5 v1.7.0a5 2026-06-28 04:12:06 -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 9837214414 fix(compaction): persist checkpoint markers to bound resume rehydration (#731)
Compaction swapped a session's in-memory history for a summary but left the full
transcript in storage, so resume() reloaded all of it -- on a long session, or
one switched to a smaller-context model, the rehydrated context overflowed the
model window and deadlocked the first post-resume send.

Persist a `_source="compaction"` marker (summary + watermark) on compaction;
resume rehydrates [summary] + [rows after the watermark] instead of the full
transcript. Full history stays in storage for /history, export, and audit;
markers are filtered from display, search, and export, and rewind/retry
truncation is floored at the marker so the summary's backing is never deleted.
The watermark and search filters count real transcript rows only. No migration.
2026-06-27 23:36:24 -07:00
Patrick Buckley 2b0b1cf73e chore: bump version to 1.7.0a4 v1.7.0a4 2026-06-27 17:20:21 -07:00
Patrick Buckley 7263b31536 fix(compaction): cap summary input budget to true capacity (review)
Address PR #730 review. _summary_input_budget_chars now caps the _MIN_SUMMARY_BUDGET_CHARS floor at the true input capacity (input_tokens), so output reserve + budgeted input + prompt always fit context_window; on a window too small to summarize it returns a sub-floor budget and _pack_blocks bails as irreducible instead of overflowing the summary call. After the half-window output-reserve bound this only affected sub-~2048-token windows, but it was a real edge.

Clarify the _CompactionIrreducibleError docstring: chunked compaction never drops or fabricates whole turns, but a single oversized block is still head/tail-truncated as summary input via _truncate_block.

Add test_budget_never_exceeds_true_input_capacity.
2026-06-27 17:06:18 -07:00
Patrick Buckley 6b6c220986 fix(compaction): chunk the summary call so it can't overflow
The compaction summary ran as a single model call sized by the per-message
token estimate, which disagreed with the head+tail-capped formatted text, so a
long history could overflow the summary call itself; the old prefix-fit also
silently dropped the most-recent messages.

Summarize the whole selection via _summarize_blocks: greedily pack the
formatted blocks into batches that each fit the summary call's own input budget
(_summary_input_budget_chars), summarize each, and recursively merge the
partials until they collapse to one. The common case (it all fits) stays a
single call. Bail to the existing False path when the input is irreducible
rather than fabricate a summary; a mid-chunk failure leaves messages untouched
(atomic swap only on full success).

Bound the summary output reserve to half the context window
(_summary_output_tokens), used by BOTH the input-budget sizing and the actual
call. compact_max_tokens defaults to the full window (32768); clamped only by
max_output_tokens it reserved the entire context for output, flooring the input
budget so compaction overflowed (or bailed as irreducible) at the
default/small-window config that needs it most. Large windows are unaffected
(compact_max_tokens stays binding).

Guard an empty summary (keep history instead of swapping in nothing and
reporting success). Fold tool-def tokens into the _last_usage-less estimate AND
the post-compaction usage anchor, so the compact-before-truncate budget doesn't
over-state free space by the tool-def count. Single-source the shared
compactor/merge prompt section (_COMPACT_OUTPUT_FORMAT) and the tool-def sizing
(_tool_def_chars/_tool_def_tokens). A just-resumed session (no _last_usage) now
counts tool-def tokens so it doesn't undercount and skip proactive compaction
until its first reply re-anchors the estimate.

Prepush review follow-ups: generation-guard the end-of-turn auto-compaction and
its resume turn so a force-cancel during the slow summary call can't compact or
persist under a new generation (matching the mid-turn and end-of-loop guards);
single-source the soft-threshold predicate (_over_soft) shared by the mid-turn
policy, _compaction_owed, and the end-of-turn check; add tests for the
pre-attempted-compaction guard and the recursion depth ceiling.
2026-06-27 17:06:18 -07:00
Patrick Buckley 65d1552ffa fix(session): provider-anchored context budget + cooperative compaction
Unify truncation and compaction on one provider-anchored fullness measure
(_estimated_prompt_tokens), closing the 80-100% dead zone where tool output
was truncated but compaction never fired. Make compaction cooperative: advise
the model to wrap up and record its plan, compact if it continues, auto-resume
after a cooperative stop, and compact-before-truncate (preserving the in-flight
tool-call turn). Floor auto_compact_pct at 0.1 (invalid 0 -> default 0.8).
2026-06-27 17:06:18 -07:00
Patrick Buckley 9f54a97cc6 fix(understone): rebalance early game and add inn turn refresh
The opening hours were a gold/HP death spiral: a fresh hero spent more gold
healing a fight than the kill paid, mid-tier foes out-damaged a starting HP
bar, the map gave no read on where harder foes spawned, and a spent day left
the player idle until the UTC rollover. This eases the on-ramp across both
shipped worlds.

Economy
- Healer drops from 2 to 1 gold per HP, so topping up no longer outruns income.
- Starting purse 20 -> 37: enough to buy the cheapest armor and one potion up
  front, a one-point DEF bump plus a heal cushion the player chooses to spend.

Combat
- Tier 2-4 common foes lose 1 ATK each, trimming the burst that could halve or
  end a fresh hero in a single bout. Rares and the boss are untouched.

Wayfinding
- The road glyph changes from "=" to a shaded path that reads as one continuous
  road in every orientation; "=" only looked right horizontally and broke into
  stacked dashes on vertical runs. Cinder's basalt path gets the same treatment
  with a crosshatch glyph free in its palette.

Rest
- Sleeping at the inn now rolls a spent adventurer into a fresh day's turns (it
  already fully heals). The top-up fires only at zero turns, so it never banks
  past the daily cap.

Verified: full suite green (+2 rest tests, ruff/mypy clean); the greedy balance
bot still clears the world 11/12 seeds at an unchanged pace on both packs.
2026-06-27 09:40:03 -07:00
Patrick Buckley 0e1972e6aa chore: bump version to 1.7.0a3 v1.7.0a3 2026-06-27 01:22:33 -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