OperonX roadmap, Q4 2026: a production AI workflow foundation¶
Written 2026-10-04 against operonx 1.14.0 (a21082d) and operonx-studio 7de6d60.
This is the consolidated plan from five investigations. Each one is a full report in this folder, and every finding
in them was checked against the source, a probe, or a reproduction you can run (evidence/):
| Track | Report | How it was done |
|---|---|---|
| 1. Hard-to-please user | track1_dogfood.md | 8 small products built on 1.14.0 with a real model; 34 frictions, each reproduced |
| 2. LangGraph audit | track2_langgraph_gap.md | LangGraph 1.2.12 cloned and run side by side; 29-row gap table |
| 3. Agent framework | track3_agents.md | operonx.agents read in full, the callbot audited, 2026 frameworks researched |
| 4. Workflow evaluator | track4_eval.md | Existing Eval/Review code read, Langfuse/LangSmith/Braintrust/Inspect researched |
| 5. Knowledge platform | track5_knowledge.md | OperonX primitives read, PageIndex/Docling/GraphRAG/ColPali researched |
The goal: production reliability, simplicity, composability and real user value, without becoming a copy of another framework.
0. The short version¶
- The happy path is good; the failure path is not. A real user built a prompt chain, a tool agent, jobs and
loops, and they worked the first time. But failures go silent: wrong totals, a
200 nullfrom a crashed request, a cancelled run that hangs, a timed-out run that keeps doing side effects. These are fixed first. - OperonX's runtime model is the right one. It is about 25× faster than LangGraph's superstep barrier on the same graph (0.019 s vs 0.509 s to start a dependent node), streams between ops with backpressure, and has deterministic context paths. We keep it and do not adopt Pregel.
- The production gaps are failure semantics and durability. No per-op retry or timeout, no crash recovery, no durable human approval. The fix is one mechanism: an append-only journal of the scheduler's own events, which gives resume, durable interrupts, idempotency keys and live traces together.
- Three new packages sit on top, never inside the core:
operonx-agents, the evaluation system (in core, because it extends the existingEval), andoperonx-kb. - Studio becomes where you debug, evaluate and inspect knowledge. The Langfuse-grade input/output view is already built (studio PR #11).
Order of work¶
| Wave | Weeks | Core | Runtime | Eval | Agents | Knowledge | Studio |
|---|---|---|---|---|---|---|---|
| W0 | 1 | Correctness bugs (§1.1) | n/a | Plan doc, baseline noise | Plan doc | Plan doc, skeleton | I/O view (done, PR #11) |
| W1 | 2–4 | Debuggability (§1.2) | Failure semantics (§2, R1) | Identity, repeats, statistics | Phase 0 spike, decision D1 | Foundation ingest | Script runs, names, errors |
| W2 | 5–8 | DX polish (§1.3) | RunContext, streams, live traces (R2) | TraceView, ScoreStore, CLI, CI | Tools, Model, llm_step |
Hybrid retrieval, citations, eval | Live runs, agent view |
| W3 | 9–14 | n/a | Journal: durable execution (R3) | Judges, Studio experiments | Runner, state, approvals | Studio Knowledge tab | Experiments, compare, Knowledge |
| W4 | 15+ | 2.0 cleanups | Threads, run queue, cron lease (R4) | Online eval, queues | Serve, evals, callbot adoption | Enrichment, tree, multimodal, graph | Online scores |
Each item ships on its own branch with its own tests. Each wave has a measured gate before the next starts.
Decisions only you can make¶
| # | Decision | Recommendation | When |
|---|---|---|---|
| D1 | Agent loop inside one op, instead of a graph back-edge | Yes, if the W1 spike shows ≤ 2 ms/turn overhead and equal trace fidelity | End of W1 |
| D2 | Agents as a separate repo operonx-agents |
Yes | W1 |
| D3 | Deprecate operonx.agents one release after operonx-agents 1.0 |
Yes | W4 |
| D4 | Reserved op keywords (id, name, start, stream, … 21 of them) move behind a namespace in 2.0 |
Changed 2026-10-05: not needed. A keyword the function takes is now its input (DX_PLAN X3), so a flat setting never collides; a clashing setting goes through f.configure(...). Flat settings stay, no deprecation (callbot has ~70 correct ones) |
W2 |
| D5 | First knowledge corpus (Edupia/educa or another) | Your call; it decides the first internal eval set | W1 |
| D6 | CI gets ClickHouse credentials to write experiments; daily judge budget for online eval | Yes; budget to be set after W2 measurements | W2 |
1. OperonX core refactoring¶
Current problems (all reproduced on 1.14.0)¶
| # | Problem | Where | Effect |
|---|---|---|---|
| C1 | .collect() re-stores every output of the collected op, so a reducer cell gets each item twice |
core/ops/graph/task_scheduler.py:918-937 |
Wrong totals; ReducerError with a dict reducer |
| C2 | An op failing inside a subgraph does not stop the op after it | core/ops/graph/graph_op.py:775-780 |
Successor runs with None; HTTP answers 200 null |
| C3 | handle.cancel() never marks the run done |
core/engine.py:140, 371-374 |
result() waits forever |
| C4 | asyncio.wait_for(engine.run()) times out but the graph keeps going |
core/engine.py:780-793 |
Side effects after the timeout |
| C5 | @op(cache=True) keys only on the op's full name |
core/ops/base.py:850-871 |
Two graphs return each other's results (A7 for B7) |
| C6 | Structured output coerces silently; JSON with any preamble fails; XML fails on & |
providers/parsing.py:198-241, 348-394 |
urgency: int = "2.5", bool("maybe") = True |
| C7 | A misspelled output key builds and returns the consumer's default | graph/validation.py (no check) |
total=0 instead of an error |
| C8 | The 1000-iteration loop cap stops silently | task_scheduler.py:1043-1057 |
No $errors entry |
| C9 | stream(mode="updates") is documented to yield InterruptEvent and never does |
interrupt_op.py:16-28 |
The stream blocks |
| C10 | LLMOp(stream=True): the final frame repeats the whole text |
providers/ops/llm.py:1134-1140 |
Delta consumers send it twice |
| C11 | Websocket frames reach the graph undecoded; a bad JSON body runs the graph instead of a 400 | app/serve/asgi.py:149-155, serve/app.py:314-318 |
Silent client, wasted runs |
| C12 | $errors keeps the first failure per op only; errors are full tracebacks starting with operonx frames |
core/states/state.py:366-380, base.py:1311-1316 |
Two failures look like one |
| C13 | Run names come from the wrong variable: services engine, jobs params, scripts out |
core/utils/auto_name.py:155-172 |
Every Studio list is mislabelled |
| C14 | Script runs are untraced by default and go to /tmp/operonx_traces/adhoc even inside a project |
telemetry/consumers/local.py:83-104 |
Not visible in Studio |
| C15 | The trace keeps an LLM call's template and variables, not the messages sent: _extract_trace_io, which renders them, has no caller |
core/ops/base.py:783-803 |
Studio has to reconstruct the prompt |
| C16 | No fake LLM provider; operonx init inside a uv project installs PyPI operonx, not the checkout |
providers/llms/factory.py, cli/init.py |
Every template hand-rolls an HTTP server |
| C17 | 21 reserved op keywords collide with domain names (id, start, stream…) |
core/ops/_params.py |
@op def f(id) warns |
| C18 | Dead code from the dropped Rust runtime: operonx pack |
cli/pack.py, graph_op.py:857 |
Traceback on any looping graph |
1.1 Correctness (W0, about one week)¶
| Change | Test that fails today |
|---|---|
C2: the batch branch does not yield when the child recorded an error and the declared outputs are all None (mirrors the streaming branch); add "<graph>.<sub>" to $errors |
test_subgraph_failure_stops_successors; serve test expects 500, not 200 null |
C3/C4: _pump catches BaseException and wakes every waiter; cancel() marks done; run()/stream() cancel the handle when their await is cancelled |
test_cancel_wakes_waiters; test_run_timeout_cancels_graph (after == 0) |
C1: _flush_collect merges only the collected vars and does not re-fire push-refs |
test_collect_reducer_no_double_write (list and dict reducers) |
C5: key = graph fingerprint + op qualname + code hash + blake2b(orjson(inputs)); bounded LRU |
test_cache_isolated_across_graphs_with_same_names |
C6: failed coercion is a field error, so max_retries fires; bool accepts only true/false/yes/no/1/0; JSON finds the first fenced or balanced block; XML retries with & escaped; add parser="json_schema" (native response_format) |
test_parsing_strict over every case in evidence/repros/parse_probe.py |
C7: validate_graph checks Ref(op, key) against statically known outputs, with "did you mean" |
test_unknown_output_key |
C8: the cap records LoopLimitExceeded in $errors; if_(..., max_iterations=N) |
test_loop_cap_reports_error |
C9: add mode="interrupts"; updates yields InterruptEvent; fix the docstring |
test_stream_updates_surfaces_interrupt_event |
C18: delete operonx pack and the Rust serialize path |
CLI help test |
Every repro in evidence/ becomes a regression test in the same PR as its fix.
1.2 Debuggability (W1)¶
- C12:
$errors[op] = {type, message, count, first_ctx}; tracebacks trimmed to user frames in the message, kept whole in the trace; a structuredLLMOpfailure lands in$errors;meta.jsongetsstatus. - C13: Job and serve pass the graph's own name;
auto_namestops scanning lines above the call and falls back to the graph function's__name__. - C14:
resolve_rootsearches upward foroperonx.toml; inside a project,Operon()traces locally by default. - C15: record the rendered request on LLM executions: call
normalize_trace_iowhen theOpExecutionis built, storemessagesbeside the variables. Gate it with the existinginclude/excluderules. - C10, C11: the final stream frame sets
final=Truewith an empty delta (full_contentcarries the text); websocket decodes JSON by default; bad bodies get 400 before a run is minted; every reply carriesx-operonx-trace-id.
1.3 Developer experience (W2)¶
- C16: ship
api_type: fake(scripted text, tool calls, status codes, delays, streaming); templates use it;operonx init --editable PATHand detection of an enclosing uv project. - C17: op settings move behind
op.configure(...); deprecate in 1.x, remove in 2.0 (decision D4). - Logging:
OPERONX_LOG_LEVEL, stderr handlers, configurable slow-op threshold (LLM and IO ops exempt). operonx serve --host --port --reload, default host 127.0.0.1 in development,/healthz.out["$cells"]for final reduced values; rewrite the staleOperondocstring; one import path per symbol.
Do not build (core)¶
- A Pregel superstep barrier, or
Command(goto=…)routing from inside op bodies. - Seven stream modes plus versioned stream APIs. Keep four modes, add
tasksandinterrupts, allow several at once. - Pickle anywhere (cache keys, state, journal).
- Rust or any second runtime.
2. Production runtime capabilities¶
Current problems¶
No per-op retry or timeout (only jobs retry, with no backoff). Errors are recorded and execution continues, with no
error routing and no fail-fast mode. Nested concurrency limits multiply (2 × 2 gave 4 concurrent ops) and there is
no per-resource rate limit. Two concurrent writers to a plain cell race silently. The checkpointer only observes, in
memory. A crashed process loses the run, and a rerun repeats its side effects. Interrupts are in-process futures
with uuid4 ids. Traces are written only when a run ends. Webhook 202 events and schedule triggers live in one
process with no lease.
Proposed architecture¶
R1 failure semantics ──► R2 identities + events ──► R3 journal ──► R4 platform
retry/timeout RunContext resume threads
error edges idempotency key durable HITL run queue
errors="raise" interrupt ids graph fp cron lease
shared limiter multi-mode stream drain reconnect
race lint live traces fork (R5)
R1 (W1): failure semantics. New operonx/core/policy.py:
@op(retry=Retry(max_attempts=4, on=TRANSIENT), timeout=Timeout(run=30, idle=5))
async def call_crm(order_id: str) -> dict: ...
Operon(g, errors="record" | "raise", max_concurrency=32) # one limiter shared by nested graphs
call_crm(order_id=x).on_error(handler) # error edge: handler(error, op, inputs)
- Retry wraps
_exec_coreinBaseOp.run. A generator is retried only before its first yield, so frames are never duplicated. Each attempt is its ownOpExecution(attempt=n). - Timeout:
asyncio.timeoutper attempt;idlebetween yields for generators.bound="cpu"ops abandon the thread and recordTimeoutError. errors="raise"sweeps the root context on the first error and raisesOpFailed(op, error). Live sessions keep today's record-and-continue default.- Build-time lint: two writers to a reducer-less cell with no path between them →
BuildError, opt out withallow_race=True. rate_limit:per resource inresources.yaml, enforced by the shared limiter.
R2 (W2): identities and events.
- A read-only RunContext contextvar: run_id, thread_id, op_path, ctx, attempt, deadline,
idempotency_key = blake2b(run_id, op_full_name, ctx). Information only; control flow stays as visible nodes.
- Deterministic interrupt_id = hash(run_id, op_full_name, ctx).
- engine.stream(mode=["updates", "custom", "tasks", "interrupts"]) yields (mode, chunk).
- Live traces: Consumer.on_execution(exec), run status running, ClickHouse and SQL stores append as ops finish.
- Child executions (await invoke(op, **inputs) inside an op records an OpExecution under the parent ctx) and
OpExecution.attrs for GenAI attributes. These are prerequisites for the agent framework.
- Providers: one normalised tool-call shape, native structured output, per-resource timeout, logprobs passthrough.
R3 (W3): durable execution through a scheduler journal. The scheduler's bookkeeping changes only on Frame,
EOF and Interrupt events. Persisting those events in arrival order, with the post-reducer cell writes the event
bus already emits, is enough to rebuild a run exactly. New package operonx/durable/:
engine = Operon(g, journal=SqliteJournal("runs.db"), durability="sync" | "async" | "exit")
h = engine.start(inputs, run_id="order-42", thread_id="cust-7")
await engine.resume("order-42") # after a crash, on any worker
await engine.resume("order-42", answers={interrupt_id: {"approved": True}})
- Replay skips ops with an
EOFin the journal and re-emits their recorded frames in sequence order. - An op that was in flight runs again (at least once) and gets the idempotency key to deduplicate external calls.
- A generator that partly ran:
on_resume="restart"replays and checks the first k yields by hash (NonDeterministicResumeon a mismatch), or"fail". InterruptOpwith a journal parks the run: the coroutine ends andresume(answers=…)drives it on.- The graph fingerprint is stored; resume refuses a changed graph unless
allow_graph_change=True. handle.drain(): no new dispatches, finish in-flight ops, statusdrained, resumable.- Off by default.
journal=Nonecosts oneis Nonecheck, so the live callbot stream path is untouched. Ingress graphs can be journaled for audit but are not resumable.
R4 (W4): platform. Threads (carry=[cells] between runs), a Postgres runs table with SKIP LOCKED workers,
webhook events persisted before the 202, schedule with a lease row, a per-thread second-message policy
(reject | enqueue | interrupt | rollback), stream reconnect ?after_seq=N, completion callbacks. R5 (later):
history(run_id) and fork(run_id, at_seq, patch) from the journal.
Tests¶
- R1:
test_retry_transient_then_success,test_generator_not_retried_after_first_yield,test_timeout_records_and_retries,test_idle_timeout_generator,test_error_edge_handler_runs_once,test_errors_raise_mode_cancels_siblings,test_build_rejects_concurrent_writers,test_nested_concurrency_shared_cap, plus a callbot-shaped benchmark with no policies set that must stay within noise. - R2:
test_trace_visible_while_running,test_killed_run_leaves_partial_trace(subprocess + SIGKILL). - R3, the core proof: a property test that generates random graphs (generators,
.parallel,.collect, branches, loops, soft edges), kills them at a random journal position, resumes, and asserts the output equals an uninterrupted run. Plustest_crash_resume_skips_completed,test_durable_interrupt_across_processes,test_replay_reproduces_reducer_and_race_outcomes,test_graph_change_refuses_resume, and a contract suite shared by the in-memory, SQLite and Postgres journals. - R4:
test_webhook_event_survives_restart,test_schedule_fires_once_across_two_workers,test_thread_carries_cells_between_runs,test_stream_reconnect_after_seq.
Do not build (runtime)¶
- Snapshot-per-step checkpoints and delta channels. The journal is append-only with periodic compaction.
interrupt()called inside an op body that re-runs the op from the top on resume. The visibleInterruptOpnode becomes durable instead.- A config-dict for runtime injection. Use a plain contextvar.
- A closed platform server. Queue, threads and cron stay open and self-hostable on Postgres.
- Temporal or DBOS as a required dependency. An adapter is a "later" item for the agent layer only.
3. Workflow evaluation¶
Current problems¶
Evals exist since 1.9.0: Eval is a Job with origin=eval (operonx/app/evals.py:342), with JSONL datasets,
five checks plus an LLM judge, a pass-rate gate and a non-zero exit code. Studio has Evals, Datasets and Review
screens. The gaps: evaluators never see the trace (runner.py:242 drops it); verdicts live only in local
items.jsonl, so CI experiments never reach Studio; no experiment identity; no repeats, so flaky cases look like
regressions; no confidence intervals; the judge is untraced, uncached and unversioned; no online eval; no
operonx eval CLI; no pytest plugin.
Proposed architecture¶
Dataset (git JSONL, content hash)
→ Experiment (an Eval job run, with a fingerprint: git sha, graph hash, config hash, dataset hash, evaluator versions)
→ Run + trace (the existing run store)
→ TraceView (same object from a live trace or a stored run)
→ Evaluators (code checks, trajectory, tool-call, budget, LLM judges as traced graphs) — run concurrently
→ Scores (one row type: code, judge, human, online, pairwise; idempotent ids) → ScoreStore
→ Metrics with CI (Wilson, clustered SE) → paired comparison (bootstrap, McNemar)
→ Gate: pass / regressed / inconclusive (+ must-pass tier, infra exit code 3)
→ Report (Markdown, JSON, JUnit) + Studio
- Placement: core
operonx, no new dependencies, statistics in pure Python.operonx.app.evalsbecomes a package and keeps its import path.ScoreStorelives inoperonx/telemetry/scores/: files + SQLite locally, ClickHouse schema v3 (experiments,experiment_items,scores,judge_cache) for teams and CI. - Evaluators keep their signature and may also take
trace: TraceViewfor trajectory matching (strict / unordered / subset / superset), tool-call arguments, per-op outputs and budgets. - Judges are operonx graphs, traced with
role=judge, versioned (rubric + model + graph hash), cached, binary by default, pairwise with position swap. A judge must show agreement with human labels (Cohen's κ, TPR/TNR) before it gates silently; reports warn when κ < 0.6 or no alignment exists. - Repeats classify cases as stable-pass, stable-fail or flaky, with pass^k.
calibratemeasures the same-version noise floor;powersays how many cases a given drop needs (a 5-point drop needs about 312). - Online eval is a scheduled Job over the run store: hash sampling, a daily budget, backfill, idempotent score ids. Never inline in a service. Failures feed review queues; score metrics feed the existing alerts.
- Surfaces:
operonx eval run|compare|report|rescore|calibrate|power|align|dataset|online, an opt-in pytest plugin, CI snippets with baseline lookup against the merge base.
Phases and tests¶
| Phase | Deliverable | Gate |
|---|---|---|
| E0 (W0) | docs/EVALS_PLAN.md; callbot QC cases run 3× on one sha |
Measured flip rate sets default repeats |
| E1 (W1) | Fingerprint, repeats, stats.py, three-state gate |
A/A simulation: same version reported "regressed" ≤ 5%; a true 10-point drop on 300 cases caught ≥ 80%; all 11 existing eval tests unchanged |
| E2 (W2) | TraceView, trajectory/tool/op-output/budget evaluators, rescore |
from_trace(live) == from_rows(stored) golden test |
| E3 (W2) | ScoreStore (files, SQLite, ClickHouse v3) |
An experiment run on host A is visible on host B; a ClickHouse outage loses no verdicts |
| E4 (W2) | CLI, reports, pytest plugin, CI | Exit-code matrix 0/1/2/3; a real MR shows the JUnit widget |
| E5 (W3) | Judges traced, versioned, cached, pairwise, aligned | κ measured on 100 human-labelled callbot reviews |
| E6 (W3) | Studio experiments, compare, case drill-down, dataset editor | Every screen opens in one hop; desktop + phone screenshots |
| E7 (W4) | Online eval, queues, score alerts, trends | One week of staging traces at 5%: judge spend within budget, service latency unchanged |
| E8 (later) | Multi-turn and simulated-user cases (callbot QC port) | Pass rates match the old harness on the same sha |
Do not build (eval)¶
A second scheduler for evals (Job is the runner); datasets that live only in a database; 1–10 Likert scales as the default judge; one judge checking several criteria; a metric zoo (Ragas, BLEU, BERTScore) in core; evaluators inline in services; a query language over scores; vendor SDKs (LangSmith, Braintrust, DeepEval) in core; an auto-loaded pytest plugin; diffs reported without a confidence interval.
4. Agent framework: operonx-agents¶
Current problems¶
operonx.agents is about 4.7k lines and already enforces the right invariants: one tool result per call,
fail-closed permissions, compaction that keeps calls and results together, cache-stable prompts. Underneath:
- Security hole:
TOOL_REGISTRYis process-global (agents/tool.py:289), dispatch looks names up there (dispatch.py:774), and the default policy allows non-destructive tools (policy.py:352). Any agent runs any tool registered anywhere in the process if the model names it. - The ReAct loop is a graph back-edge, and most features needed a workaround for it (
react.py:168, 550,memory_ops.py:162,model_ops.py:137). - Nothing is durable: approvals are in-process futures, sessions are lists, the checkpointer observes.
- A destructive tool with no approval handler waits 300 s silently; tool arguments are never validated; tool calls and sub-agents are untraced; a 3-call agent trace is 70 records, mostly plumbing.
- The callbot, the one production consumer, uses none of it. Its real needs are a deadline, model fallback, runtime enum constraints, cancel-safe commits and redaction.
Proposed architecture¶
A separate repo and package, operonx-agents, with two front doors:
llm_step: a typed op with a deadline, model fallback, runtimeChoice(from_input=…), logprobs confidence andon_timeout. This is what the callbot needs.Agent+Runner: an explicit loop inside one op (decision D1), with model calls, tool calls and sub-agents recorded as child executions. A serialisableRunStatesaved at turn boundaries handles approvals, crash recovery, sessions and cancel-safety with one mechanism. A non-idempotent tool in flight at a crash is answered "outcome unknown", never re-run.
operonx_agents/
tools/ tool.py (typed from signatures, ModelRetry), toolset.py (per agent), dispatch.py, policy.py
model/ model.py (fallback, normalised usage), output.py (native | tool | prompted)
step.py llm_step
run/ agent.py, runner.py, state.py (RunState, StateStore), limits.py (UsageLimits), events.py
context/ session.py (memory | redis | sql), compaction.py (persisted summary), prompt.py
compose/ as_tool, as_op, handoff (later)
safety/ hooks.py (before/after model, before/after tool), redaction.py
mcp/ stdio + streamable HTTP (2026-07-28 stateless revision), annotation gating
serve.py agent_service, SSE/WS events, /resume
evals.py trajectory evaluators on TraceView
Must-have / later / avoid¶
- Must: typed tools with argument validation; per-agent toolsets (closes the security hole);
llm_step;Modelwith fallback and native/tool/prompted structured output; a runner with turn budget and parallel tool calls;RunState+ resume, HITL as interruption data;UsageLimitsincluding cost and wall clock; sessions with persisted compaction; hooks as guardrails; agent-as-tool; typed event stream; child-step tracing with GenAI attributes; MCP over stdio and streamable HTTP. - Later, on a concrete trigger: handoffs, OTel exporter, skills with progressive disclosure, Letta-style memory blocks, A2A, MCP elicitation/sampling, Temporal/DBOS adapters, DSPy-style prompt optimisation, speech-to-speech adapters.
- Avoid: agent base classes; a global tool registry; crews, group chat and swarms; a framework-owned planner;
RAG bundled in the agent layer; unsandboxed code execution; long in-process approval futures; the
Heartbeattimer (the servescheduletrigger does it); YAML-defined agents; auto-written long-term memory without review.
Phases and tests¶
| Phase | Deliverable | Gate |
|---|---|---|
| A0 (W1, 4 days) | Spike: back-edge loop vs 150-line in-op loop; prototype child executions; llm_step deadline on the in-house gateway; native structured-output support per endpoint |
≤ 2 ms/turn at 5 concurrent runs; trace screenshots desktop + phone; you decide D1 |
| A1 (W2) | Core prerequisites in operonx (R2: child executions, attrs, op deadline, raise mode, provider normalisation) | Tests listed in §2 |
| A2 (W2–3) | Tools, Model, llm_step |
Schema generation across 25 signature shapes accepted by OpenAI and Anthropic validators; callbot shadow replay of 200 recorded turns: ≥ 99% intent agreement, p95 no worse |
| A3 (W3) | Runner, RunState, limits, events, sessions | Kill between "in flight" and "tool finished": idempotent tool re-runs, non-idempotent gets "outcome unknown"; cancel mid-turn writes nothing |
| A4 (W3–4) | Approvals, composition, safety, MCP | An approval survives a restart; a model naming another agent's tool gets "unknown tool" |
| A5 (W4) | Serve, Studio, evals, migration shims | HTTP + WS end to end with an approval round-trip; screenshots |
| A6 (W4) | Callbot adopts llm_step on refactor/operonx-studio only |
Shadow diff + 5-CCU load test, TTFA p50 ≤ 0.86 s |
Every phase includes at least one run against a real model, not only scripted ones.
5. Knowledge and document platform: operonx-kb¶
Current problems¶
OperonX has the retrieval primitives (EmbeddingOp, RerankOp, VectorSearchOp, DocFetchOp, Media) but no
ingestion, chunking, BM25/hybrid, upsert or delete op, versioning or citations. BaseVectorStore has no
delete (vector_stores/base.py:31,60). An unknown resource category silently loads as a raw dict
(resource_hub.py:242-246). The Anthropic provider drops native citations (anthropic.py:232-235). Searching an
empty index is silent.
Proposed architecture¶
A separate repo, operonx-kb (import operonx_kb), sibling of Operon. Every stage is an operonx op; ingest,
query and maintenance are @graphs run as Job, Service and Runbook. No new runtime.
Connector → Parser → Structurer → Chunker → Enricher → Encoders → Index
(files, (Docling, (element tree (structural, (contextual, (dense, (FAISS, pgvector,
S3, web) text, with page evidence summaries, BM25, FTS5, PG FTS;
Office) + bbox spans) units) entities) pages) Qdrant later)
↓
Query: Retriever(s) → Fuser (RRF) → Reranker → hydrate through catalog → Context → Answer → Verify citations
- Three-tier storage. Catalog (SQLite locally, Postgres in production) is the store of record: documents, versions, element tree, chunks, spans, enrichments, graph. A SHA-256 blob store holds files and page images. Indexes are derived and can be dropped and rebuilt. Every hit is hydrated through the catalog, which is the consistency gate, so updates and deletes need no distributed transactions.
- One provenance invariant. Each version has one canonical text. Every element and chunk carries character spans into it, and every span maps to page + bounding box. A citation is a span, so it can be verified and clicked; eval labels are quotes, so they survive a chunker change.
- Cheap updates. Content-derived chunk ids stable across versions; hash-keyed parse, enrichment and embedding caches; an unchanged paragraph is never re-embedded. A model or settings change builds a new index generation that replaces the old one only after an eval passes.
- Retrieval modes, one contract
(query, collection, filter, k) → hits: hybrid dense + BM25 (default only if it wins), tree retrieval (PageIndex-style, from the element tree), graph (cheap co-occurrence first), visual page retrieval (fused with text). - Filters: a closed
KBFiltercompiled per backend, with a conformance test that includes tenant leakage.
Phases and tests¶
| Phase | Deliverable | Gate |
|---|---|---|
| K0 (W0–1) | PLAN.md, model, hashing, ids, span utilities, fakes |
Span invariant on 100% of golden docs |
| K1 (W1–3) | Catalog, blob store, parsers, chunkers, dense index, ingest Job, deletes, rebuild, verify | Re-ingest of an unchanged 200-doc corpus: 0 embed calls; one-paragraph edit: ≤ 3 re-embeds; purge leaves 0 entries |
| K2 (W4–6) | Lexical, hybrid, rerank, citations, KBFilter, eval sets (incl. ≥ 100 Vietnamese cases) |
Hybrid becomes default only if it beats dense on Recall@10 on 2 of 3 sets; citation precision ≥ 0.9 |
| K3 (W7–8) | Admin API + Studio Knowledge tab | 20/20 sampled citations open the right page and box |
| K4–K6 (W9+) | Contextual enrichment and tree, multimodal, graph | Each default-on only with a measured lift. K4 built (gate open: credits), K6 merged (opt-in). ⚠️ K5 multimodal OUTSTANDING — needs a GPU machine; see operonx-kb BACKLOG.md |
Upstream asks to operonx¶
U1 BaseVectorStore.delete() + VectorUpsertOp/VectorDeleteOp; U2 entry-point plugin discovery for resource
categories and an error (not a raw dict) for unknown ones; U3 carry provider-native citations in extras;
U4 (later) sparse embeddings; U5 remove the dead RerankingType.COHERE; U6 move MediaStore out of telemetry.
The KB ships small shims for U1 and U2 so it never waits on a release.
Do not build (knowledge)¶
Content in vector payloads, or any index as the source of truth; LangChain, LlamaIndex or Haystack as
dependencies; a god rag() graph with a long kwargs list; an open filter DSL; whole-document re-indexing as the
update strategy; GraphRAG community summaries by default; Neo4j before tables prove insufficient; a parser's type
(DoclingDocument) as the core model; non-permissive parsers or weights (MinerU, Marker) in defaults; our own OCR,
layout or embedding models; vision-only or vectorless-only defaults without eval evidence; Studio importing the KB
package (HTTP contract only).
6. Studio integration¶
Done (studio PR #11, branch feat/trace-io)¶
The Langfuse-grade input/output view, keeping the Tree, timeline and Workflow views:
- An LLM call's input is its conversation, role by role, rendered from the template and recorded variables with
LLMOp's own rule (str.format_map); unrecorded variables stay {name} chips. Variables, template and knobs in
folds.
- Its output is the reply (JSON pretty-printed, tool calls, thinking) with a facts line: model, tokens, cost,
stop reason. Parsed fields follow.
- Every other value reads as text, labelled fields, chips or media. Formatted/JSON switch; widen button.
- 59 JS tests (9 new), 401 studio tests; checked on real callbot ClickHouse runs, desktop + phone + dark.
Next, by wave¶
| Wave | Studio work | Depends on |
|---|---|---|
| W1 | Script runs listed (C14); correct run names (C13); structured errors with trace-id links (C12) | §1.2 |
| W1 | Show recorded messages when present, falling back to the template rendering (C15) |
§1.2 |
| W2 | Live runs: running status, nodes appear as ops finish |
R2 live traces |
| W2 | Agent view: internal plumbing ops collapsed into turn → model → tool calls, ordered by wall time |
R2 child executions + internal marker |
| W2 | Retry attempts and timeouts on execution rows | R1 |
| W3 | Experiments with CI, A/B compare, case drill-down to the blamed op, dataset editor with case history | E3–E6 |
| W3 | Interrupted runs: parked approvals with an Approve/Deny action that calls resume |
R3 |
| W3 | Knowledge tab: collections, documents, page viewer with bbox overlays, chunk inspector, query playground with clickable citations | K3 (admin HTTP API) |
| W4 | Online score trends, review queues with rubrics, judge alignment | E7 |
| W4 | Journal history and fork from a step | R5 |
Every Studio change ships with desktop and phone screenshots.
Do not build (Studio)¶
A tree-of-spans clone of Langfuse (we keep timeline + workflow); Studio importing operonx-kb or
operonx-agents (HTTP and the run/score stores only); a second trace format; per-screen bespoke data fetching where
one hop serves the page.
7. Dependencies between tracks¶
§1.1 correctness ─► R1 failure semantics ─► R2 RunContext/child execs/streams ─► R3 journal ─► R4 platform
│ │ │ │ │
│ │ │ └─► A1 ─► A2 ─► A3 ──┴─► A4 (durable approvals)
│ │ └─► E2 TraceView (agent trajectories)
└─► E1 statistics (independent) └─► Studio live runs + agent view
U1/U2 upstream asks ─► K1 (shims let K start in W1)
E3 ScoreStore ─► Studio experiments; K3 admin API ─► Studio Knowledge tab
- Nothing in evaluation or knowledge waits on the journal.
- The agent framework's durable approvals (A4) need R3; its first win (
llm_step, A2) needs only R2. - The live callbot path stays unchanged until A6, and only on
refactor/operonx-studio.
8. How each item ships¶
- A plan doc is committed before a large phase (
docs/EVALS_PLAN.md,docs/AGENTS_V2_PLAN.md,operonx-kb/PLAN.md,docs/DURABLE_PLAN.md). - One branch per phase, commits batched, merged when the phase gate is recorded.
- Every claim is backed by a measurement or a failing test first. A benchmark has to measure the shape being decided.
- Every guide snippet stays executable (
tests/guide).