feat: full thread support in the MCP (read, list, lifecycle, annotate) — OPIK-7443#152
Merged
Merged
Conversation
… (Phase A)
A thread groups traces by thread_id within one project. This lights up the
read + list surfaces (the annotate surface already existed):
- read('thread', id) -> {thread, messages, messagesTruncated}: metadata via
POST /threads/retrieve + the turns via GET /traces filtered by thread_id.
Each message carries a trace_id so the agent can read('trace', id) to drill in.
- list('thread', project_id=…) enumerates a project's threads.
Implementation:
- opik_client: get_thread (POST-with-body retrieve), list_threads, list_traces
filters=, and a _post_json read helper (sibling of _get_json). Protocols widened.
- registry: _fetch_thread / _list_threads / _compress_thread + a needs_project
flag on EntityHandler (thread only); FetchFn widened to pass project kwargs.
- uri: opik://projects/<p>/threads/<t> + pasted web thread-link parsing
(looks_like_thread_url), project_id on ParsedURI.
- read_tool: project gate + plumbing; server/read tool gains project_id/
project_name params (id max_length 512->2048); instructions advertise thread.
Thread reads require project scope (a thread id is unique only within a
project): pass a thread link/URI or project_id/project_name. On a messages
load failure the read degrades to empty messages but surfaces messagesError
rather than silently reporting "no messages".
Reviewed against opik-backend source (endpoints, thread_id filter operator,
TraceThread.id-carries-identifier). ruff + strict mypy + 1107 tests green;
read.json/list.json conformance snapshots regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se C)
Two new write operations let the agent manage a thread's status via the
universal write tool:
- thread.close -> PUT /v1/private/traces/threads/close (status -> inactive)
- thread.open -> PUT /v1/private/traces/threads/open (status -> active)
Both take {thread_id, project_name|project_id} and require the
trace_span_thread_log scope (already in ALL_WRITE_SCOPES).
Implementation:
- models: ThreadClose/ThreadOpen on a shared _ThreadLifecycle base; a
model_validator requires one of project_name/project_id (the BE's
TraceThreadIdentifier validator rejects a project-less body with 400 —
caught locally with a thread_project_missing recovery code).
- registry: two WriteOperation entries (supports_batch=False).
- dispatch: an explicit branch (the dispatcher has no generic fallthrough —
it ends in raise RuntimeError, so a branchless op would crash). Fixed
endpoint + exclude_none dump = the wire body verbatim.
Review correction: close/open are PUT (not POST) and return 204 — verified
against opik-backend TracesResource (@put /threads/{open,close}), the
generated Python SDK, and the existing PUT thread-feedback path.
description + schema tools regenerate from the registry; write.json and
schema.json conformance snapshots refreshed. ruff + strict mypy + 1131
tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Threads are keyed by a user-supplied thread_id STRING, but score.create /
comment.create typed target_id as UUID — so annotating any real thread failed
with uuid_parsing. This gives both a single, uniform contract:
To annotate a thread, pass the thread_id string you see in list('thread') /
read('thread') as target_id, plus the project. The server does the rest.
- models: shared _AnnotationTarget base — target_id: str, optional
project_name/project_id, and a model_validator that (a) requires + canonicalizes
a UUID for trace/span, (b) requires a project for thread (a thread_id is unique
only within one). ScoreCreate/CommentCreate extend it. target_id description
spells out the per-target rule.
- dispatch: comment.create on a thread resolves the thread_id string to the
thread's model UUID via get_thread (the BE's POST /threads/{id}/comments takes
the model UUID), so callers never juggle two identifiers. score.create thread
keeps sending the string thread_id. trace/span bodies stay minimal.
Review-driven hardening: non-404 backend errors during the resolve surface as a
structured BackendError (not a raw OpikError that bypasses the JSON envelope);
dropped a wrong id fallback; canonicalize non-dashed UUIDs so a locally-valid id
is BE-valid; truncate=True on the resolve fetch; dry_run notes that a thread
comment's path resolves at execution.
Verified against opik-backend source (addThreadComment @PathParam UUID,
FeedbackScoreBatchItemThread project fields) and live e2e on dev: score + comment
a thread by its string id, both read back on the thread. ruff + strict mypy +
1139 tests green; no conformance drift (per-op model schemas aren't snapshotted).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
list('thread') / list('trace') required project_id (a UUID), while every other
thread tool (read/score/comment/close/open) also accepts project_name — so an
LLM with only a project name had to make an extra list('project') call first.
Now the list tool takes project_name as an alternative:
- run_list gains project_name; forwarded only for project-scoped lists
(project_id in list_required_kwargs) so it can't reach a workspace-wide
list_fn as an unexpected kwarg. The required-kwarg check accepts
project_name in lieu of project_id, and the error names both.
- server.py list tool exposes project_name (mirrors read); docstring updated.
The client methods and OpikListClient Protocol already accept project_name, so
this is purely surfacing it. ruff + strict mypy + 1143 tests green; list.json
conformance snapshot regenerated (adds the project_name param).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`make lint` runs `ruff format --check`; three files from the thread work had formatting drift (only `ruff check` was run locally). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
awkoy
force-pushed
the
awkoy/opik-7443-read-thread-support
branch
from
July 24, 2026 08:50
b4a4739 to
5e677a2
Compare
alexkuzmik
approved these changes
Jul 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Full thread support in the MCP (OPIK-7443)
Makes
threada first-class Opik entity across the MCP tool surface. Before thisPR, threads were only a write/annotate target (and even that was broken for real
threads — see the fix below). Now an agent can read, list, annotate,
and close/reopen a thread through one uniform, self-documenting contract.
The contract (why it's good LLM UX)
One rule everywhere: pass the
thread_idstring you see inlist('thread')/read('thread'), plus the project (id or name). The server absorbs everybackend asymmetry so the model never juggles identifiers:
read('thread', <thread_id>, project_id|project_name)→{thread, messages, messagesTruncated}; each message = one turn's trace input/output + atrace_idtoread('trace', …)list('thread', project_id|project_name)write score.create/comment.createwithtarget="thread",target_id=<thread_id string>+ projectwrite thread.close/thread.openwith{thread_id, project}What's in here (4 commits)
feat(read/list)— thread as a readable/listable entity.read('thread', …)returns metadata + the messages list (traces filtered bythread_id, up to 200 inlined, sorted ascending).list('thread', project_id=…)enumerates a project's threads. New client methods
get_thread(
POST …/threads/retrieve),list_threads,list_traces(filters=…), and a_post_jsonread helper. URI/link parsing:opik://projects/<p>/threads/<t>and pasted Opik web thread URLs (
looks_like_thread_url).readgainsproject_id/project_name(a thread_id is unique only within a project).feat(write)—thread.close/thread.openlifecycle.Two write operations toggling thread status (active ↔ inactive), via the
universal
writetool. The dispatcher's_build_requestis an explicitif name == …chain (no generic fallthrough — a branchless op would hit aRuntimeError), so both got a real branch. Verified against opik-backend:these are
PUT(not POST) and return 204.fix(write)— thread score/comment bythread_idstring.score.create/comment.createtypedtarget_idasUUID, so annotating anyreal thread failed with
uuid_parsing(thread_ids are user-supplied strings).Now a shared
_AnnotationTargetbase validates per target: UUID fortrace/span (canonicalized), the
thread_idstring for thread, with a requiredproject. For
comment, the dispatcher resolves the string thread_id → thethread's model UUID (the BE's
POST /threads/{id}/commentsneeds the UUID) sothe caller never sees the asymmetry.
feat(list)—project_namefor trace/thread lists.list('thread'/'trace')now acceptsproject_nameas an alternative toproject_id, matching every other thread tool and removing a forcedlist('project')UUID round-trip.Verification
(
TracesResource@PUT /threads/{open,close},addThreadComment(@PathParam UUID),FeedbackScoreBatchItemThreadproject fields, thethread_idfilter operator).Multi-agent adversarial review on each phase caught and fixed real issues
pre-merge (POST→PUT verb, string-vs-UUID identifiers, a missed
schema.jsonsnapshot, a silent message-load degrade, non-404 resolve errors bypassing the
write envelope).
create a thread →
list(by id and by name) →read(turns in order) →score+commentby string id (comment resolves to the model-UUID path) →read back (both annotations present) →
close(inactive) →open(active).All green.
ruff+ strictmypy(src + tests) clean; 1143 tests pass; conformancesnapshots (
read/list/write/schema) regenerated.Notes
thread.delete, feedback-scoredelete, thread tag updates, thread stats. Clean follow-ups.
is unchanged (target_id UUID still required and now canonicalized).
🤖 Generated with Claude Code