Repository navigation
feat: pluggable thread persistence with THREADS_BACKEND=local - #166
Mrdifferent2022 wants to merge 7 commits into
Conversation
jerelvelarde
left a comment
There was a problem hiding this comment.
I reproduced local-mode blockers on b2711a3 through the actual runtime HTTP path and targeted runner regressions. Keeping changes requested until these are resolved:
- New side chats must persist through the runtime's agent-cloning path, with restart coverage.
- Owner isolation and destructive thread operations need hardening before this backend is safe to merge. I'm keeping the detailed security reproduction out of the public thread.
- Failed runs and concurrent sends must terminate cleanly, and completion must remain behind the durable write.
- In-memory history eviction must not truncate the persisted conversation.
I have a local fix and regression coverage in progress. @matthewhand @Mrdifferent2022, let's agree whether #94 or #166 should carry the final implementation so we can consolidate the work and preserve both contributions. Neither PR needs to be closed while that is being settled.
…access Address the four blockers from review on CopilotKit#166: - side chats now persist through the runtime's per-request agent cloning: ConversationAgent exposes its owner (clone() preserves it) and the owner lookup reads it, instead of relying on a WeakMap keyed on the original factory instance - thread detail endpoints (messages/events/state) are served owner-scoped from the database; other owners' threads return 404 and clear deletes only the authenticated owner's durable rows - failed runs terminate the stream cleanly (best-effort persist, no hang when no RUN_FINISHED arrives) and concurrent sends surface the runner's Thread already running error instead of hanging - persistence merges runs by runId into the stored record, so in-memory eviction can no longer truncate the durable conversation; v1 records with thread-level events migrate on read Regression coverage: clone-path persistence, failed-run termination, concurrent-run rejection, eviction-merge survival, v1 migration, and owner-scoped detail endpoints with owner-local clear.
|
All four blockers are addressed in
New regressions cover each point, and I verified the full flow over real HTTP: main chat + brand-new side chat persist, a second app instance over the same database replays both after "restart", detail endpoints are owner-scoped, and clear stays owner-local. Full suite: 503 tests, 0 failures. On #94 vs #166: I'm happy for #166 to carry the final implementation — it currently has the broader surface (owner-scoped endpoints, mobile capability hiding, demo support, operator docs), and I'd gladly take @matthewhand's commits or co-authorship here. If you'd rather consolidate into #94 or your own branch, say the word and I'll port. If your in-progress local fix covers any of these differently, happy to align — the reproduction notes you kept private would help confirm we hit the same paths. |
|
Three follow-up commits since the blocker fixes, all from testing the backend against a real model (Moonshot/Kimi via an OpenAI-compatible gateway):
Regression coverage: |
|
Reading the persistence layer as someone who shipped the same architecture in a fork (***/openmuse-standalone, I'm the dev — Alpha, so take this as field notes, not authority): the run-merge, memory-eviction and per-owner clear handling here covers the same corners I had to get right. One boundary worth making explicit before merge. Inside PersistentAgentRunner the unit of isolation is the bare threadId: hydrate() returns early whenever ɵGLOBAL_STORE.peek(threadId) already exists, without an owner check, and the findRecord() cache-miss path scans every owner's chat-threads rows to resolve an id. The HTTP layer closes this today — every /threads route authenticates ownerFromRequest(req) and compares record.owner === owner before reaching the runner — so I do not see a live cross-tenant hole in the current routes. The risk is that it only holds if every future caller remembers to authorize first. A later internal entry point (an admin/debug tool, a background job, a webhook replay) that calls runner.run/connect with just a threadId would hydrate and append onto another owner's thread, and the fallback scan would resolve the id across owners anyway. Two cheap options, in case they fit: key the runner's index/caches by (owner, threadId) and thread owner through hydrate/persist so a mismatch refuses; or leave the runtime store global but make findRecord take the expected owner and reject a row owned by someone else. Single-user sample mode stays on local-user, so nothing changes there; it just moves the invariant from "every caller must check" into the persistence boundary itself. Happy to be wrong if the runtime already namespaces per request — I'm reading against @copilotkit/runtime 1.70.1's process-global store. |
Make CopilotKit Intelligence replaceable via the runtime's own AgentRunner seam so self-hosted deployments run keyless: - THREADS_BACKEND=intelligence|local (default intelligence, unchanged behavior; an explicit intelligence selection without a key still fails loudly at startup, never a silent downgrade) - PersistentAgentRunner extends InMemoryAgentRunner: hydrates threads from the database before run/connect, holds RUN_FINISHED until the snapshot write completes, and surfaces a failed write as a visible RUN_ERROR - owner-scoped, database-backed thread listing (the runtime's built-in local fallback is process-global); rename/archive stay Intelligence-only and are hidden in the native UI rather than faked - the demo harness follows the same setting and runs keyless Addresses the review feedback on CopilotKit#94 (awaited persistence, runner regressions, operator docs) and fixes CopilotKit#73.
…access Address the four blockers from review on CopilotKit#166: - side chats now persist through the runtime's per-request agent cloning: ConversationAgent exposes its owner (clone() preserves it) and the owner lookup reads it, instead of relying on a WeakMap keyed on the original factory instance - thread detail endpoints (messages/events/state) are served owner-scoped from the database; other owners' threads return 404 and clear deletes only the authenticated owner's durable rows - failed runs terminate the stream cleanly (best-effort persist, no hang when no RUN_FINISHED arrives) and concurrent sends surface the runner's Thread already running error instead of hanging - persistence merges runs by runId into the stored record, so in-memory eviction can no longer truncate the durable conversation; v1 records with thread-level events migrate on read Regression coverage: clone-path persistence, failed-run termination, concurrent-run rejection, eviction-merge survival, v1 migration, and owner-scoped detail endpoints with owner-local clear.
…kend Local mode now serves rename, archive/restore and delete owner-scoped from the database, and names untitled threads with the configured model, falling back to a truncated first user message. The app UI exposes the same controls on both backends; intelligence behavior is unchanged.
… options
- intercept GET /api/copilotkit/info in local mode and set
threadEndpoints.mutations=true so the SDK issues rename/archive/delete
calls against the app-layer endpoints instead of refusing client-side;
realtimeMetadata stays false (Intelligence-only feature)
- pass modelOptions {temperature: 1, max_output_tokens: 64} to summarize:
Moonshot/Kimi rejects any temperature other than 1, and the default
maxLength-derived token cap truncates CJK titles; caller always wins
- thread list rows match the Intelligence backend layout: title line plus
Rename / Archive|Restore / Delete buttons (Delete in danger style);
numberOfLines=1 keeps long titles on one line
… call The @TanStack summarize activity maps maxLength across several adapter layers and Moonshot/Kimi gateways reject or truncate under those mappings (kimi-k3 allows only temperature=1 and spends reasoning tokens before the summary). Call the chat-completions endpoint directly for openai-compatible providers so every knob (temperature, max_tokens) is explicit; anthropic and google keep the summarize activity. Any failure still degrades to the truncated first-message title.
Addresses the review note that the runner's isolation unit was the bare threadId: a warm process-global store entry skipped any owner check, and the findRecord fallback scan resolved an id across every owner's rows, so the invariant only held while every caller remembered to authorize first. - findRecord takes an expectedOwner and resolves a foreign record to null (cache hit and database scan alike). - run() passes the agent's owner into hydrate; hydrate and persist refuse with "Thread not found" when the durable record belongs to someone else, instead of appending to the other owner's in-memory history. - connect carries no agent, so the route layer remains the authority for replay, as before. Also rebases onto upstream/main (signed-URL method scoping, DELETE /api/session, computer deployment resolution) and restores the resolveComputerDeployment startup call alongside the threads-backend assertion. Regression tests cover a warm in-memory foreign threadId and a cold-start cross-owner run. Full suite: 549 tests, 0 failures.
4ff990f to
2fcdea9
Compare
|
Good catch — addressed in You were right that the invariant only held while every caller remembered to authorize first. It now lives at the persistence boundary:
One boundary I could not move down, for the record: the runtime's |
The direct chat-completions fetch had no abort signal, and the summarize activity path for anthropic/google had no deadline either — a hung model gateway would leak the fire-and-forget titling promise (and its connection) forever. Both paths now time out after 30s and degrade to the truncated first-message title; the summarize timer is cleared when the call settles first so it never holds the process open.
|
@jerelvelarde — following up on the consolidation question from your first review, since both branches have now sat for a while. Where things stand:
My suggestion, to keep both contributions intact: let #166 carry the final implementation — it already contains the hardening you asked for on both PRs — and credit #94 as the originating design in the commit message and docs. If you'd rather consolidate the other way, I'm happy to port the parity work over to a PR against @matthewhand's branch instead; just say which shape you prefer and I'll do the legwork. No urgency if you're mid-review elsewhere — mainly want to make sure this isn't waiting on a decision I can unblock. |
What
Makes CopilotKit Intelligence replaceable for self-hosted deployments via the runtime's own
AgentRunnerseam, so OpenMuse runs fully keyless with threads on its own database:THREADS_BACKEND=intelligence|local(defaultintelligence). The default path is byte-for-byte unchanged; an explicitintelligenceselection without a key still fails loudly at startup — never a silent downgrade.PersistentAgentRunner(apps/server/src/threads/local-runner.ts) subclasses the runtime's publicInMemoryAgentRunner: it hydrates threads from the database beforerun/connect, so history survives API restarts.RUN_FINISHEDevent is held until the snapshot write completes, and a failed write surfaces as a visibleRUN_ERRORinstead of a silent loss (the blocker flagged on feat: run chat fully offline with CopilotKit Intelligence optional #94).Builds on #94 (same seam, same motivation) and addresses the review feedback left there on 2026-10-06: awaited persistence with visible failure, runner regression coverage (including delayed/failed writes followed by a restart), operator documentation, and a rebase onto current
main. Fixes #73.Test plan
tests/threads-provider.test.ts(7): RUN_FINISHED held behind a delayedStore.put; failed write → visible RUN_ERROR with nothing saved; history replayed by a fresh runner afterɵGLOBAL_STORE.clear()(restart survival); per-owner scoping; durableclearThreads; provider selection matrix; runtime version pinned against the store contract.tests/local-threads.test.ts(5): boots with noCPK_INTELLIGENCE_API_KEYand zero Intelligence contact; main-thread provisioning; owner-scoped pagination; honest 422s for rename/archive; approvals flow unchanged.docs/PLUGGABLE-THREADS.md(config matrix, capability matrix, operator notes); README and.env.exampleupdated.