Skip to content

release: sync master with dev (fix stale master CI) - #10

Merged
Roy-Kid merged 81 commits into
masterfrom
dev
Jun 1, 2026
Merged

Roy-Kid merged 81 commits into
masterfrom
dev

Conversation

@Roy-Kid

@Roy-Kid Roy-Kid commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

master is a stale snapshot (79 commits behind dev) whose CI fails (ModuleNotFoundError: molexp.cli.run_cmd — a test references a since-renamed module). dev is green. Fast-forward master to dev to fix master CI and refresh the aggregate mirror.

Roy-Kid and others added 30 commits May 19, 2026 13:27
`molexp.Path` subclasses `pathlib.PurePosixPath` so paths under a
remote (RemoteFileSystem) workspace can use the same `/` arithmetic
without exposing local-only I/O methods that would silently hit the
wrong filesystem. Exported from `molexp` and listed in CLAUDE.md's
allowed cross-layer primitives.

Foundation for the follow-up PRs that migrate Folder.path()/.resolve()
return types and unify the agent layer's "local-only" divergence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduce a `PathArg = str | os.PathLike[str]` type alias and update every
method signature in the `FileSystem` Protocol + `LocalFileSystem` +
`RemoteFileSystem` to accept it. Implementations normalize via
`os.fspath()` at the boundary so the rest of the code can pass either
`molexp.Path` or `str`.

`LocalFileSystem` already wrapped each path with `pathlib.Path()` so it
needed minimal changes; `RemoteFileSystem` does string-level POSIX
manipulation, so each entry point now `os.fspath()`s its input before
the `.strip("/")` / `.rsplit("/")` calls.

Backward-compatible: callers passing plain `str` continue to work
unchanged. This is the bridge that lets PR 3 flip Folder.path() /
.resolve() to return `molexp.Path` without touching every caller.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migrate every workspace-layer Folder subclass to return `molexp.Path`
(a `PurePosixPath` subclass) instead of `str`:

- `Folder.path()` / `.resolve()` / `.child_dir()` / `_container_dir()`
- `Workspace.root` / `Workspace.resolve()`
- `Project.project_dir`, `Experiment.experiment_dir`, `Run.run_dir`
- `CacheFolder.entry_path()`

The previous `str` return type was a hedge against accidental
`folder.path().exists()` calls hitting the local filesystem on a remote
workspace. `molexp.Path` removes that hazard structurally — it has no
I/O methods — so we recover the `/` operator and path arithmetic
without re-introducing the silent-local-FS bug.

Drop the long "str vs Path divergence" doc block from `folder.py`:
agent-layer subclasses no longer need to diverge (see PR 4).

Coerce to `pathlib.Path` at every site that does genuine local I/O
(`pathlib.Path` for `.exists()` / `.mkdir()` / `.read_text()`):
- workspace assets (`AssetManifest`, `DataAssetLibrary`)
- server routes (`_scope.py`, `run.py`, `responses.py`)
- `tree_monitor._read_run_json`
- `RunContext` (its `work_dir` is local-only by design)
- `_make_execution_id` in workflow runtime

Update the workspace / server / cli / workflow tests to add
`from pathlib import Path` imports and wrap workspace paths with
`Path(...)` at the I/O boundary. No assertion semantics change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Agent-layer Folder subclasses now use `self._fs` for every I/O call
instead of binding to `pathlib.Path`. They no longer assume "local
filesystem" and can mount on a `RemoteFileSystem`-backed workspace.

- `Agent.child_dir()` / `AgentSession.child_dir()` / `PlanFolder.child_dir()`:
  use `parent._fs.join(...)`, return `molexp.Path`
- `from_disk(child_dir: PathArg, ...)`: pass `fs=parent._fs` into
  `_load_metadata` so reads go through the workspace's transport
- `materialize` / `save`: `self._fs.mkdir` + `_save_metadata(fs=self._fs)`
- `AgentSession.messages_path`: built via `self._fs.join`
- `read_messages` / `write_messages`: use `self._fs.exists / read_bytes /
  write_bytes / remove / mkdir` — no more `path.exists()` / `tmp.replace()`
- Drop the "local-only override" / "agent state is local-only by design"
  docstrings — that divergence is gone

PlanFolder is fundamentally local (it manages generated source +
on-disk YAML artefacts), so its `_ensure` / `_resolve` helpers and
public `*_dir()` accessors continue to return `pathlib.Path`. The
Folder hooks (`resolve` / `child_dir` / `path`) return `molexp.Path` to
satisfy the framework contract.

New acceptance test (`tests/test_agent/test_remote_session.py`) uses a
recording-spy `_SpyFileSystem` wrapping `LocalFileSystem` to assert
that mount, message round-trip, empty read, write-then-remove, and
from_disk reload all route through fs methods (no direct pathlib
calls). 5 tests, all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`preflight.py` was reaching `from pydantic_ai.mcp import MCPServerStdio`
inside a function — function-local but the AST-based import guard
(`tests/test_agent/test_import_guard.py::test_pydantic_ai_imports_confined_to_pydanticai_subtree`)
doesn't care about scoping; any import statement outside `_pydanticai/`
trips it.

Extract the handshake helper into `_pydanticai/mcp.check_stdio_handshake`
(natural home — sits next to `build_mcp_server`); `preflight._check_mcp_stdio_handshake`
becomes a one-line delegating shim. Behavior unchanged.

Restores the import-boundary firewall: no `pydantic_ai` imports outside
`agent/_pydanticai/`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A previous CLI reorganisation moved init under `workspace <TARGET>` and
left `runs prune` / `target` subcommands and the top-level `init`
shortcut unregistered. Tests for them have been red for a while.

- `molexp init [PATH]`: new thin shim in `cli/init_cmd.py` delegating to
  `Workspace(path).materialize()`. Idempotent. Defaults PATH to cwd.
- `molexp runs prune ...`: `cli/prune.py` had a `register(run_app)`
  function but nothing called it — wire it under a new `runs` subcommand
  group. Also coerce `run.run_dir` to pathlib.Path inside prune.py for
  the local-FS `.exists()` / `.relative_to()` calls (molexp.Path is a
  PurePosixPath subclass with no I/O methods).
- `molexp target add/list/remove/test`: new `cli/target_cmd.py` built on
  top of the existing `workspace/targets.py` CRUD helpers. Mirrors the
  argv the tests expect: `--scratch`, `--scheduler`, `--host`, `--port`,
  `--identity-file`, `--path`. `test` runs a 3-step smoke probe (command
  exec → mkdir → file round-trip) over the target's transport.

Also delete `tests/test_cli/test_run_cmd_helpers.py` — it imports
`molexp.cli.run_cmd` which was deleted in the same reorganisation; the
test only triggered a pytest collection error.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The unified-FileSystem refactor (365008f) intentionally stopped passing
RunStorePersistence into pydantic-graph's runner (the new GraphBuilder
API doesn't expose `iter_from_persistence`), but it also accidentally
dropped the construction itself. Result: `<run_dir>/executions/<id>/`
never appeared, even though the runtime's module docstring still claims
"per-frame snapshots are still written by RunStorePersistence for
observability".

Restore the construction (without re-injecting it as the graph runner's
persistence) in both `GraphWorkflowRuntime.execute` and `.start`. The
class's `__init__` writes the initial workflow.json — enough to satisfy
the observability contract: tooling can rely on the per-execution
directory always existing after a run, even if per-frame updates aren't
flowing through.

Fixes `tests/test_workflow/test_runtime.py::*::test_runtime_runs_with_
duck_typed_run_context_no_workspace` and `::test_run_dir_kwarg_writes_
workflow_json`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three leftover files were sitting in the working tree:

- `tmp/`: pytest's `tmp_path` output left by a stray
  `pytest --basetemp=tmp/` run. Pure junk. Deleted and added to
  `.gitignore` so it can't return.
- `tests/test_workflow/test_graph_start_node.py`: regression test for
  the pydantic-graph 1.x `inputs=` requirement (the `_GRAPH.run`
  helpers `_run_graph` / `_iter_graph` exist solely to keep this
  parameter from getting forgotten at the three call sites). Real
  guard against a real recurring bug — committed.
- `tests/test_workspace/test_from_disk_invariants.py`: regression test
  for Folder `from_disk` overrides losing `_fs` on the reconstructed
  attrs dict (manifests as `AttributeError: 'Project' object has no
  attribute '_fs'` only after a workspace reload). Validates the
  `base_from_disk_attrs` contract. Real guard — committed.

Both regression tests pass cleanly (5/5).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ndpoints

Server-process descriptor registry for remote workspace roots — the
first of three sub-specs (remote-workspace-via-ui-01-server-registry)
that let the UI mount a workspace whose root lives on a remote HPC
node.  Active-workspace switching (sub-spec 02) and the Settings UI
(sub-spec 03) build on this surface.

* WorkspaceTarget — frozen pydantic descriptor (name / host / port /
  identity_file / ssh_opts / root_path).
* WorkspaceTargetRegistry — server-process-scope CRUD backed by
  ~/.molexp/workspace_targets.json via atomic_write_json (crash-safe);
  add raises ValueError on duplicate, get/remove raise KeyError.
* New routes on /api/workspace/targets — GET / POST / DELETE /
  POST {name}/test.  Connectivity probe mirrors /api/targets/{name}/test
  (mkdir + file round-trip) and returns HTTP 200 + ok=false on failure
  so the UI can render inline.  Endpoints work before any workspace is
  open (don't depend on get_workspace).
* schemas/workspace_targets.py — request/response models; re-imports
  TargetTestCheck / TargetTestResponse from schemas/targets.py.
* dependencies.py — get_workspace_target_registry (lazy singleton) +
  get_remote_fs_factory (test seam).
* Soak test (tests/test_workspace/test_workspace_remote_fs_soak.py)
  exercises Workspace -> Project -> Experiment -> Run over a spy-wrapped
  LocalFileSystem to verify every I/O routes through self._fs and the
  non-LocalFileSystem branch skips .resolve() — retires the
  medium-confidence librarian risk that Workspace(fs=RemoteFileSystem)
  end-to-end CRUD was unverified.

49 new tests passing.  ty + ruff clean on touched files.  OpenAPI JSON
and ui/src/api/generated/ regen left out of this commit so sub-spec 02's
WorkspaceOpenRequest discriminator can land in a single coherent regen.
…witching

Second of three sub-specs (remote-workspace-via-ui-02-active-switching).
Makes the active workspace switchable between a local path and a
registered remote WorkspaceTarget descriptor while preserving the
existing {path, create_if_missing} request shape.

* WorkspaceOpenRequest -> discriminated union via pydantic v2 `Tag` +
  callable `Discriminator` (back-compat: missing `kind` defaults to
  "local").  New members: WorkspaceOpenLocalRequest /
  WorkspaceOpenRemoteRequest exported alongside the alias.
* _workspace_cache rekeyed to `dict[tuple[str, str], Workspace]` —
  local-vs-remote workspaces no longer collide.
* set_active_workspace_descriptor(name) + _workspace_descriptor_override
  mirror set_workspace_path_override; mutual exclusion enforced on each
  setter.  _active_workspace_key centralises override precedence;
  get_workspace branches on kind and builds Workspace(root_path, fs=...)
  for the remote branch via target_to_filesystem_for_workspace_target.
* register_workspace_subscriber + _drain_workspace_subscribers — the
  SSE-on-switch teardown seam.  Both override setters drain
  subscribers *before* reset_workspace_cache so closers can still
  resolve the outgoing workspace.  Sync and async closers supported;
  closer failures are swallowed (best-effort drain).
* POST /api/workspace/open rewritten to dispatch on kind: local branch
  preserves existing semantics; remote branch resolves through the
  WorkspaceTarget registry (404 if absent) and never auto-creates the
  remote root.
* Coverage: 11 unit tests pin discriminator + cache + mutual exclusion +
  drain semantics; 1 integration test exercises local -> remote -> local
  round-trip through TestClient and asserts state isolation.

ty + ruff clean on touched files.  142 -> 154 server tests passing.
TS-client regen (workspace open + targets models) lands in a follow-up
commit alongside the sub-spec 03 UI work.
Third of three sub-specs (remote-workspace-via-ui-03-ui-settings-page).
Lands the user-facing UI for managing remote-workspace descriptors and
switching the active workspace.  Code-level scope only; the spec's four
`ui_runtime` acceptance criteria (driven via `/mol:web` against
`dev:mock`) remain pending and gate the spec to `code-complete`.

* SettingsPage reorganized into a two-tab layout via the existing
  `@/components/ui/tabs` primitive — Remote workspaces (default) and
  Compute targets (existing ComputeTargetsPanel unchanged).
* RemoteWorkspacesPanel mirrors ComputeTargetsPanel: list with Test /
  Set active / Delete actions; an inline ok/fail Test result block;
  Active badge on the currently-mounted descriptor; delete disabled on
  the active row with an explanatory title.
* AddRemoteWorkspaceForm + AddRemoteWorkspaceDialog mirror the
  AddTargetForm/AddTargetDialog pattern (controlled-state inputs,
  `<Label htmlFor>` on every field, submit calls the regenerated
  WorkspaceService.createWorkspaceTarget…).
* `app/state/workspaceSwitchEvents.ts` (new) — small `workspace-switching`
  CustomEvent emitter mirroring `mcpEvents.ts`.  Fired on Set-active so
  long-lived workspace-bound resources (EventSource, file watchers) can
  tear down before the cache is invalidated.
* MSW handlers — `mocks/handlers/workspace_targets.ts` (new) seeds three
  remote-workspace fixtures and answers GET/POST/DELETE/POST-test;
  `mocks/handlers/workspace.ts` extended to dispatch
  POST /api/workspace/open on `kind` (defaults to "local" for
  back-compat); a `mockWorkspaceTargetsMap` re-export lets the local
  workspace handler look up the remote descriptor name.
* No hand-edits to `ui/src/api/generated/` — the regenerated workspace
  service / models from sub-spec 02 land in a separate regen commit.

Status: code-complete.  4 `ui_runtime` criteria pending (Settings tab
default, Add Remote happy path, Test pass/fail inline, Set-active flips
badge + refreshes tree) — drive via `/mol:web` against the dev:mock
server when ready.
…1/05)

New agent-layer package molexp.agent.modes._planning — pure frozen-pydantic
data models, the substrate the four-mode restructure (Plan/Author/Run/Review)
builds on. Five clusters: IntentSpec, the typed PlanGraph DAG, CapabilityGraph,
the explicit PlanState lifecycle + legal-transition table, and PlanDiff +
ApprovalGate. Purely additive — no mode behaviour changes; 148 tests.
New src/molexp/agent/harness/ — the pi-inspired shared runtime every mode
sits on: typed AgentEvent stream, Session append-only entry-tree +
SessionStorage repository (jsonl + in-memory), context compaction via
Router, ExecutionEnv subprocess abstraction, AgentHarness + uniform hook
registry unifying the three ApprovalGates.

AgentMode.run is now harness-based (async AgentEvent stream); AgentRunResult
gains .events; AgentRunner builds/injects the harness and adds run_events().
The legacy codegen PlanMode and the ReviewMode placeholder are deleted —
specs 03 & 06 rebuild them on the harness. ChatMode migrated. 1315 tests.
PlanMode rebuilt as a read-only typed planner running on the AgentHarness:
a 7-stage plain-async pipeline (SynthesizeIntent -> ClarifyIntent ->
ExploreCapabilities -> SynthesizeCandidates -> SelectPlan ->
PreflightPlanGraph -> EmitApprovedPlan) emitting AgentEvents, with a
plan-diff repair loop and the approve_direction gate via harness.approve.

Produces a typed PlanGraph + ApprovedPlanHandoff; writes no executable
code. PydanticAICapabilityProbe recreated (molmcp-backed) and wired so
ExploreCapabilities probes capability in production. 1373 tests.
…4/06)

AuthorMode materializes an approved typed PlanGraph into a validated
experiment workspace: lowers PlanGraph -> molexp.workflow.WorkflowContract,
generates workflow.py + per-task source + tests + IR + manifest, runs each
generated task's test through the harness ExecutionEnv (isolated subprocess
+ hard timeout + confined cwd) with an LLM debug->repair loop, gates all
file writes behind approve_materialization, and emits a
MaterializedWorkspaceHandoff. Codegen + the deterministic WorkflowContract
normalizer recreated against the typed surface. 1424 tests.
RunMode executes a materialized experiment workspace: demands the
approve_execution gate, loads the workflow via its entrypoint, binds it to
a workspace Run/RunContext and runs it through the public molexp.workflow
API, projects per-step progress onto the typed PlanSteps (RunProgress),
honours RetryPolicy on transient failures, and emits a PlanDiff /
RepairEscalation on unrecoverable failure. PlanState gains running +
completed with their legal transitions. 1492 tests.
…6/06)

ReviewMode — a read-only harness-based AgentMode that reviews an existing
plan / materialized workspace / completed run against the IntentSpec + typed
PlanGraph. Three pure checkers (intent conformance, capability evidence,
lifecycle consistency) feed a typed ReviewVerdict with per-step findings, an
overall pass/fail/needs-changes outcome, and a PlanDiff that feeds the shared
repair loop. Writes only its own verdict artefact. Completes the six-spec
agent-modes-restructure chain. 1526 tests.
… agent layer

CLAUDE.md agent-layer charter, the Architecture agent section, and the
data-type-ownership table rewritten to describe the harness + four
pipeline modes (Plan/Author/Run/Review on AgentHarness, AgentMode.run as
an AgentEvent async generator). examples/agent/{chat,plan}_mode.py
rewritten against the new AgentRunner/harness API; both run clean.
Apply ruff format and fix pre-existing ruff check errors
(ANN202 / ARG002 / ANN002 / ANN003 / RUF059) in files unrelated to the
testability-driven-decomposition feature, so the lint gate is green.
PlanMode now recursively splits candidate plans until every step is
small enough to carry an independent, low-cost isolated test.

- _planning: PlanStep gains a required IsolatedTestSketch test_sketch
- tasks_planning: refine_until_testable recursive split stage + the
  testability invariant in _CANDIDATE_SYSTEM_PROMPT
- plan_graph_preflight: 8th structural check every_step_isolated_testable
- author/codegen: tightened isolated-test prompt + test_sketch threading

Bundled agent-layer fixes from the same work session:
- review.py simplified to ReviewDecision / ReviewPolicy / cli_ask
- AgentRunner gains approval= wiring the harness before_approval hook
- capability probe: per-need bounded discovery, fixing UsageLimitExceeded
- PlanMode intent/candidate prompts: identifier-style required_outputs
Add AgentMode.get_flowchart() -> str, a base-class method that renders
a mode's stage pipeline as a Mermaid flowchart TD.

- mode.py: frozen PipelineEdge / ModePipeline declarative types + the
  self-contained _render_pipeline_flowchart builder (no workflow/pg import)
- AgentMode gains a pipeline class attribute + the inherited get_flowchart()
- all five modes declare their pipeline next to name
- fix: PlanMode's EmitApprovedPlan stage was never wrapped in
  harness.stage(); _run_approval is now bracketed so PlanMode emits 7
  timed stages, matching its seven-stage docstring
- per-mode no-drift test: AST-extracted harness.stage() literals must
  equal the declared pipeline.stages
Add InteractiveMode — the agent layer's first emergent mode: the LLM
drives a read-only tool loop (read_file / list_directory / search_code,
workspace-confined) via pydantic-ai's native Agent.iter(), exposed
through the new Router.stream_agentic surface and three loop events
(TokenDeltaEvent / ToolCallStartedEvent / ToolCallCompletedEvent).
A /plan prefix or the run_plan_pipeline tool delegates to the
structured PlanMode pipeline for auditable plan output — composition,
not inheritance.

Add the 'molexp agent' CLI command: a multi-turn REPL consuming
run_events() with a rich per-event renderer in the CLI layer.

Also fixes a pre-existing stale-.pyc race in the AuthorMode debug-loop
subprocess (PYTHONDONTWRITEBYTECODE) surfaced by the fuller suite.
Rework PydanticAICapabilityProbe into a draft->ground pipeline. After
the no-tool drafter, every drafted api_ref is verified against the real
source by an MCP-attached agent using a two-tier strategy: an index
query, escalating to reference-following (get_source on __init__.py
re-exports) when the query is inconclusive. Verified refs fold into
evidence via a pure _fold_grounding function; a need whose every ref
failed verification is re-drafted with the rejection fed back, bounded
by max_grounding_iterations.

Targets the two diagnosed capability_evidenced preflight failures:
LLM-hallucinated API names, and real re-exported symbols the old
discovery agent left unevidenced. The CapabilityProbe protocol,
ProbeResult, and capability_projection are unchanged.
ProviderError.__setattr__ rejected all post-_initialized writes,
including Python's __traceback__ assignment during exception
propagation. Any ProviderError raised inside an @asynccontextmanager
(harness.stage() — every mode) crashed with a masking AttributeError
instead of surfacing the real error.

Allow dunder attributes through the immutability guard; data-field
immutability for kind / node_id / tier / cause / attempts is preserved
(existing immutability tests still pass).

+3 regression tests in test_errors.py: direct __traceback__ set,
raise-from chain, async-with propagation (the live-run crash repro).
Drop the procedural / outdated sections that no longer reflect how
work happens (and that misled recent design discussions by being
cited as authoritative):

- Default workflow (which /mol:* skill when)
- Commands (pip install / pytest / npm — operational)
- After changing the FastAPI backend (procedural recipe)
- Adding a New Workflow Task / API Route / UI Renderer (procedural)
- Test Organization (tactical layout convention)
- Skills (references retired /molexp-* + the /mol:* plugin)

Keep only the architecture-related design principles: layer charters,
layer details (#1-5), key patterns, packaging, data-type ownership,
what-must-never-change.

557 → 440 lines.
Wire the two last no-tool agents in the agent layer through molmcp
so they can verify project API against source instead of guessing from
training data:

(1) PlanMode needs-drafter — '_build_needs_agent' now accepts
    'toolsets' / 'tools' / 'output_retries' kwargs; the probe wires the
    existing molmcp '_server' into it; '_NEEDS_SYSTEM_PROMPT' mandates
    source-verify (search_source / get_signature / get_source) before
    emitting any api_ref. '_draft_needs' adds 'async with' and degrades
    to empty needs on MCP failure (probe never-raises contract held).

(2) AuthorMode debug-loop repair — new
    '_pydanticai/debug_repair.py' exposes 'build_repair_callable(
    workspace, model) -> Callable[[str], Awaitable[GeneratedModule]] |
    None', returning a closure around a 'pydantic_ai.Agent[None,
    GeneratedModule](toolsets=[MCPToolset], output_type=,
    output_retries=)'. The closure type is stdlib 'Callable', so
    'modes/author/' stays SDK-free. '_apply_targeted_fix' /
    'run_task_debug_loop' / 'materialize_plan' accept an optional
    'repair=' and route through it when supplied, else fall back to the
    legacy 'router.complete_structured' path. AuthorMode gains
    'repair_model' / 'workspace' ctor kwargs and a
    '_build_repair_callable' helper that lazy-imports
    '_pydanticai.debug_repair'.

Strict pydantic-ai native — no new Protocol class, no wrapper class,
no factory module. 'Agent(toolsets=[MCPToolset], ...)' + 'async with
agent' + 'agent.run(...)' is the whole abstraction.

+6 regression tests: drafter source-verify, repair agent escalates on
AttributeError, '_apply_targeted_fix' routes through repair when
supplied and falls back when None.
The drafter and debug-loop repair agents shipped in capability-probe-
grounding + mcp-ground-no-tool-agents were calling tools that don't
exist in molmcp (`search_source`, `list_symbols`, `get_signature`,
`get_source`). The real catalog/browse primitive is
`molmcp_find_capability(task=...)` plus `molmcp_describe_symbol` /
`molmcp_outline` / `molmcp_search_symbols`.

Rewrite both prompts around the browse-then-select pattern the user
designed: 'LLM queries the catalog, picks matches' — not 'LLM guesses
then verifies.' The drafter MUST call `molmcp_find_capability` for
every need before emitting it, accept any returned match whose
summary semantically matches the capability (not just name-perfect
hits), and try 3+ phrasings before giving up. Repair has a 4-step
DISCOVERY PROTOCOL on `AttributeError` / `ImportError` / `NameError`.

Live-validated (DeepSeek + real molmcp): the drafter now discovers
real symbols like `molpy.tool.polymer.polymer`,
`molpy.io.forcefield.xml.read_oplsaa_forcefield`,
`molpy.core.script.Script.from_text` at 1.0 confidence — capabilities
it was hallucinating before.

Tests updated to fake the real molmcp tool names + return shape;
examples lean on `Use the molcrafts toolchain — discover the right
APIs from the project source` instead of pre-specifying api_refs.
Drafter / grounding / repair prompts no longer hardcode molmcp tool
names. Each prompt describes three abstract tool roles (CATALOG/OUTLINE,
CAPABILITY/SEARCH, DETAIL/LOOKUP) and tells the LLM to match by name
pattern from its own tool list. Drafter now requires a CATALOG/OUTLINE
call as Step 1 before drafting any need; grounding and repair recommend
the same when the missing module is unfamiliar.

Fixes the v4 observation that DeepSeek-chat skipped the catalog and
went straight to semantic search, producing empty drafted needs ~50%
of the time. Mandating the outline read first uses molmcp's own
hierarchical map ("call this first to see what a codebase contains")
as intended.
Phase 01 of agent-mode-stage-pipeline chain — introduces the three
new harness primitives that subsequent phases (PlanMode 02 and the
remaining five modes 03) migrate onto. No concrete mode migrates yet;
existing run() bodies are untouched.

  - Stage ABC (harness/stage.py) — plain Generic ABC (not pydantic;
    arbitrary_types_allowed=True is forbidden under agent/). Subclasses
    pin ``name`` and implement ``run`` as an async generator yielding
    AgentEvents + a terminal value. NameOnlyStage is the transitional
    placeholder phase 01 wraps mode pipeline declarations with;
    deleted at chain end.
  - RepairPolicy (harness/repair.py) — frozen pydantic; declarative
    "when event kind X fires, rewind to stage Y up to N times, then
    route to Z" routing. Per-mode repair *executors* in
    modes/{author,run}/repair.py stay; this is the policy.
  - execute_pipeline (harness/pipeline.py) — plain async loop walking
    a ModePipeline's Stage tuple: brackets each in harness.stage,
    forwards events, threads terminal values, honours repairs and an
    optional lifecycle_validator. No pydantic_graph, no compiled
    graph object.
  - ModePipeline (mode.py) — promoted from frozen-pydantic side-band
    metadata to a plain class carrying live Stage instances + entry +
    repairs + lifecycle_validator. PipelineEdge stays frozen pydantic.
    AgentMode.run_pipeline added as the default delegation helper
    (AgentMode.run remains @AbstractMethod).
  - 6 modes' ``pipeline = ModePipeline(stages=(...))`` declarations
    rewrap each name as ``NameOnlyStage(name=...)``. No run() bodies
    touched.

Substrate tests: 45 new + backward-compat updates. Full repo suite
1648 passing; ruff + ty clean for touched files; import guard +
no-drift invariants preserved.
Roy-Kid and others added 27 commits May 29, 2026 10:06
…ple (plan-mode-revival-04)

Stages now resolve their upstream input BY ARTIFACT KIND at run time
(require_latest) — the clean Mode-driven contract; no constructor-id
threading (the manual-driver pattern is gone). All 8 planning/validation
stages refactored; full_pipeline.py + 12 test files migrated.

PlanMode(Mode) declares the 9-stage idea->experiment-plan->WorkflowIR->
BoundWorkflow->molexp.workflow-source sequence (auto-grant approval).
examples/harness/plan_mode_live.py runs it end-to-end against REAL DeepSeek
— verified live: a NL draft produced a validated 4-task NEMD workflow that
compiled to a real Workflow and cleared the gate.

Honest prompt grounding (NOT validator-gaming): workflow_ir/bound_workflow/
workflow_source SYSTEM_PROMPTs now explain what a valid DAG / resource-policy
/ molexp.workflow program is (incl. an API example) so DeepSeek emits valid
output.

New test_plan_mode.py (7 tests: offline run, ModeResult, provenance
user_plan->workflow_source, live-example import safety, examples not
collected). 1665 tests pass; ruff + ty clean. ac-012 (live-run docs) parks.
…ess + reorganize examples

The word 'Mode' now belongs unambiguously to the harness orchestration
concept (harness.Mode / PlanMode). The agent layer's LLM-conversation
'modes' are renamed to 'loops':

- AgentMode->AgentLoop, ChatMode->ChatLoop, InteractiveMode->InteractiveLoop
  (+ *Config); agent/mode.py->loop.py, agent/modes/->agent/loops/.
- AgentRunner(mode=)->loop= (+ self.loop); CLI + tests + CLAUDE.md updated.
- harness docstrings that contrast against the agent concept now say AgentLoop.
- harness.Mode / PlanMode / ModeResult untouched.

Examples reorganized to the current architecture:
- agent/: chat_loop.py + interactive_loop.py (the 2 shipped agent loops;
  fixed the stale AgentHarness->AgentRuntime docstring).
- harness/: plan_mode_offline.py (NEW — PlanMode end-to-end, deterministic,
  no key, via StubAgentGateway; prints generated workflow source + lineage +
  audit) + plan_mode_live.py (real DeepSeek). Deleted the pre-PlanMode
  full_pipeline.py (raw StageRunner demo, subsumed by PlanMode).
- examples/README.md: new Loop/Mode note + Harness Layer section.

1665 tests pass; ruff + ty clean; all three example scripts run.
…ile profiles into molexp.profile

molexp.config is now a live molcfg.Config instance (defined in molexp/__init__.py)
— the process-global, in-code place to register runtime values (notably LLM API
keys), mutated with molcfg-native syntax and never read from the environment:

    import molexp
    molexp.config["deepseek_api_key"] = "sk-..."

The file-based, per-run profile config (ProfileConfig / MolCfg / load_molcfg /
find_default_config / normalize_profile_name) moves to molexp.profile — a
genuinely separate concern from the global app config.

- molexp/__init__.py: `config: molcfg.Config` instance + docstring; no wrapper verbs.
- rename src/molexp/config/ -> src/molexp/profile/ (drop the old runtime.py);
  update consumers (workspace.run, cli.workspace.run, workflow _pydantic_graph).
- agent router _coerce_model_value reads molexp.config.get("deepseek_api_key").
- examples chat_loop / interactive_loop / plan_mode_live use molexp.config[...];
  delete the offline stub; README documents the in-code key flow.
- tests/test_config -> tests/test_profile; import-guard prose + CLAUDE.md +
  .claude/notes/architecture.md updated (config = instance, profile = file config).
Add a reasoning-delta channel parallel to text deltas so a model's
chain-of-thought (DeepSeek reasoner, Claude extended thinking) is no
longer silently dropped:

- ThinkingDeltaChunk in the Router protocol (agent/router.py), joined
  into the AgenticChunk union.
- _thinking_delta_chunk / _request_stream_chunk translate pydantic-ai
  ThinkingPart / ThinkingPartDelta (the seam that used to return None).
- ThinkingDeltaEvent in the AgentEvent union (16 kinds).
- InteractiveLoop maps the chunk to the event.
- CLI renders reasoning as a dim/italic collapsible-style stream,
  distinct from the answer.

TDD throughout; 810 agent+harness+cli tests green.
…s (00a)

Rebuild the deleted server-side agent session runtime on the new
AgentRunner surface as a new molexp.server.agent_runtime subpackage:

- AgentTurn / AgentSessionRuntime / AgentSessionRegistry — plain runtime
  containers owning the background turn task (drains run_events), the
  collected AgentEvent stream, and per-workspace session lookup.
- Registry as a process-singleton accessor (get_agent_runtime) reset on
  app shutdown via lifespan (cancels + awaits in-flight turns).
- Relight create/get/list_sessions in routes/agent.py through the
  registry; model resolved from in-code molexp.config with a 503
  pre-flight when unset; a runner-factory test seam injects a scripted
  Router so tests need no LLM. Runtime objects never cross response_model.
- Import-guard: server.schemas never imports server.agent_runtime.
- stream_events / approvals / messages stay 503-stubbed (00b/00c).

First link of the agent-live-event-streaming-ui prerequisite chain.
TDD throughout; full suite 1682 passing, ruff clean, ty unchanged.
…scoped)

A mid-session user message now starts a fresh background turn on the
existing Session via AgentSessionRuntime.start_turn (404 unknown session,
409 mid-turn — no interleaving), returning a wire MessageResponse.

Re-scoped from the original 00b: the out-of-band approval loop is
deferred. Implementation found the agent before_approval gate is dormant
(no shipped loop dispatches it) and ReviewPolicy carries no sink, so a
server http_ask cannot emit ApprovalRequestedEvent — approvals are
blocked on agent-layer work and not needed for the streaming goal.

Full suite 1686 passing; ruff clean; ty unchanged (69).
Relight stream_events to deliver a session's live AgentEvent flow over
SSE, lighting up /api/agent-tasks/{id}/events through the existing
delegation:

- agent_runtime/serialize.py: AgentEvent -> 'data: {json}\n\n' frames +
  terminal 'done' control frame + single 'error' frame.
- AgentTurn fan-out: a lock makes (append + push-to-subscriber-sinks)
  atomic with (snapshot + register), so subscribe() yields replay-then-
  tail with no event lost or duplicated at the seam.
- Route: copied molq _SSE_HEADERS, fail-fast 404 before the stream,
  done after mode_completed, one error frame on a failed turn.

Deterministic tests drive the StreamingResponse body_iterator directly
(no TestClient race); a gated Router exercises the mid-turn subscribe.
Full suite 1692 passing; ruff clean; ty unchanged (69).
Register the AgentEvent kind-discriminated union as the documented 200
model of the agent-tasks events route (responses=), so app.openapi()
emits its 16 member component schemas + discriminator — what link 02's
npm run generate:api needs to emit narrowed TS types. Re-export the union
from server.schemas (it supersedes the generic SessionEventResponse for
the stream; the snapshot shape stays). Add scripts/dump_openapi.py: a
deterministic, server-boot-free sorted-key dump to repo-root openapi.json.

Re-scoped from the original 01 (the SSE-route relight moved to 00c).
Full suite 1694 passing; ruff clean; ty unchanged (69).
…cabulary (02)

The UI now speaks the new AgentEvent kind vocabulary end-to-end:

- EVENT_META rekeyed to the 16 snake_case kinds (incl. thinking_delta /
  token_delta); old PascalCase keys removed; unknown kinds still fall
  back to the neutral row.
- normalizeStreamFrame(): live SSE AgentEvent frames {kind,timestamp,...}
  -> {type:kind, ts:timestamp, payload}; done/waiting -> null. Wired into
  AgentViewer es.onmessage so snapshot + stream key uniformly on event.type.
- groupEventsIntoTurns splits turns on mode_started (no user_message_received
  in the union); isResultEvent -> mode_completed/plan_emitted.
- derivePendingUserRequest -> trailing clarification_required (no request_id).
- AgentViewer event.type conditionals + PlanCard/approval/tool blocks rekeyed.
- ApiSessionEvent stays = SessionEventResponse (type carries the snake_case
  kind) — deliberately NOT repointed to the generated union (avoids the
  agent_tasks review-sync cascade).

228 rstest pass; tsc/biome add no new errors over baseline. 3 ui_runtime
criteria parked for /mol:web.
Render the agent transcript as a live stream on top of 02's vocabulary:

- foldStreamedTurn(events) -> {answer, thinking, toolCalls}: a pure
  reducer concatenating consecutive token_delta into the answer and
  thinking_delta into a separate reasoning string, and collapsing each
  tool_call_started/completed pair into one ToolCallState (FIFO by
  tool_name); answer falls back to mode_completed.text when no tokens
  streamed.
- ThinkingBlock: default-collapsed, dim/italic, 💭, 'Thinking…' while
  streaming. ToolCallRow: started (spinner ⚙) -> completed (✓/✗ + summary)
  in place.
- AgentViewer TurnCard memoizes the fold over the turn's events and renders
  the thinking block, tool-call rows, and the token-by-token answer; 02's
  batch event rows are untouched.

6 reducer unit tests; 234 rstest pass; tsc/biome add no new errors in
changed files. 4 ui_runtime criteria parked for /mol:web.
Make dev:mock exercise the live streaming agent UI so /mol:web (and manual
inspection) can observe it:

- agent.ts: the /agent-tasks/:id/events handler now streams a timed
  new snake_case AgentEvent sequence (mode_started → thinking_delta×2 →
  tool_call_started/completed → token_delta×4 → mode_completed → done),
  ~250ms/frame; the delayed snapshot event uses mode_completed/plan_emitted.
- agent_admin.ts: default mock provider apiKeySet=true so the new-goal
  form is enabled (the real backend gate doesn't apply to the mock).
- db/index.ts: getAgentSession resolves by taskId as well as sessionId
  (routes look up by the /agent-tasks/:taskId param) — fixes a 404 on
  task detail + event stream.

Verified via /mol:web: 03-stream's 4 ui_runtime criteria pass.
…ntic-ai)

The hooks/approval extension seam was wired at both ends but never
energized: zero hooks.dispatch() calls anywhere — no shipped loop fires
any of the 5 hook points, and the InteractiveLoop's read-only tools need
no approval. It also reinvents what pydantic-ai 1.97 provides natively
(DeferredToolRequests / ApprovalRequired / ToolApproved for approval,
event_stream_handler for hooks).

Delete it (behavior-preserving):
- remove agent/hooks.py + agent/review.py (HookRegistry/HookPoint/
  ReviewDecision/ReviewPolicy/cli_ask)
- AgentRuntime bundle drops the hooks field (session+router+execution_env)
- AgentRunner drops the approval= param + _build_hooks
- agent.__all__ shrinks to the 5 stable names; CLI _make_runner drops
  approval=cli_ask
- CLAUDE.md: strip the perishable approval-primitive / hooks enumerations
  (CLAUDE.md should carry abstract conventions, not stale symbol lists)

Out of scope: the server/UI plan-review surface (separate, stale-in-its-
own-way; its own follow-up). 1687 tests pass; ruff/ty green (no new).
The plan-review / approval feature was old-architecture cruft, never fed
by the live agent flow: the server runs InteractiveLoop (token/thinking/
tool/mode only, never plan_emitted/approval_requested), and the review-
sync scanned events for the non-existent PascalCase "PlanCreatedEvent",
so reviews were never created, /reviews always returned empty (no UI
consumer, no test), and respond_approval/respond_plan were 503 stubs.

Delete it (behavior-preserving):
- server: review_store.py + reviews.py (whole), review-sync helpers +
  approve/plan-decision routes in agent_tasks.py, respond_approval/
  respond_plan in agent.py, and the dead request/response schemas
  (Approval/Plan/Review*).
- ui: PlanCard.tsx + the actionable approval block + onApprovalRespond/
  onPlanResolved threading in AgentViewer; respondApproval/respondPlan in
  api.ts; the reviews + approve/plan-decision MSW handlers.
- generated client: drop ReviewsService + the review/approval models +
  the AgentTasks approve/plan-decision ops.

Kept: the plan_emitted/approval_requested AgentEvent kinds (real harness
events) + their EVENT_META labels (neutral row render); harness PlanMode/
ApprovalGate untouched. If plan-review UX is wanted later it gets rebuilt
fresh on the harness path.

Server 1685 pytest + UI 234 rstest pass; ruff/ty/biome/tsc no new errors;
dead-ref sweep zero.
A non-standard molecular dataset (reference: a QM9 download via molnex)
can carry its own loader as a same-stem `.py` sidecar that defines exactly
one concrete `molpy.io.BaseTrajectoryReader` subclass. molexp discovers the
sidecar without importing it, and only on an explicit preview request imports
it under a private (non-`__main__`) module name, instantiates the single
reader with the dataset path, takes a host-owned `islice` of frames, and
renders them.

Server:
- preview.py: discover_reader_sidecar (existence-only, no-import),
  load_sidecar_reader (private-name importer + exactly-one-subclass),
  preview_frames (host islice cap), frames_to_extxyz, snapshot_reader
  (headless molvis), asset_has_sidecar.
- GET /api/assets/{asset_id}/preview?format=frames|png.
- Typed 4xx exceptions (404 missing, 422 zero/ambiguous/broken) — never 500.
- has_preview_sidecar flag on the asset listing (existence-only signal).

UI:
- Thread hasPreviewSidecar through FileMatchContext; molvis FileTypeContribution
  matches sidecar-backed datasets that hit no extension pattern.

Tests: ac-001..009 (ac-008 QM9 reference reader skip-gated; ac-005 PNG
skip-gated on molvis; ac-010 live UI wiring deferred to /mol:web).
Previews are strictly index-driven: they operate on a *registered* dataset
asset (resolved by asset_id through the catalog). There is no workspace
file-tree scanning and no path-based preview of unregistered files. The
sidecar is not an asset — just the same-stem .py sibling of the registered
asset's resolved path.

- Rename discover_reader_sidecar -> resolve_sidecar (it resolves a known
  registered path's sidecar; it does not scan/auto-discover). Clarify the
  module/contract docstrings accordingly.
- workspace: DataAssetLibrary.register_in_place — register an existing
  workspace file as a DataAsset without copying into a payload dir, so the
  original stem (and its sidecar sibling) is preserved. New "reference"
  ImportAction.
- server: POST /api/assets/data/register registers an in-place file; rejects
  paths outside the workspace (400). End-to-end register -> preview test
  (also guards the molpy XYZ-writer atom-count fix at the molexp boundary).
Wires the registered dataset asset's sidecar flag from the catalog index
through the workspace file tree into a molvis "Preview" tab.

- server: list_workspace_files (?include=catalog) annotates each registered
  asset node with hasPreviewSidecar (index-gated; resolve_sidecar on the
  asset's resolved path). No file-tree scanning of unregistered files.
- ui: thread assetId + hasPreviewSidecar from the file node -> selection ->
  getPluginForFile/canHandle -> FilePreviewContentProps.
- ui: molvis FilePreviewPlugin "molvis:dataset-preview" (canHandle keys off
  hasPreviewSidecar); MolvisDatasetPreview mounts molvis-core and renders the
  frames fetched from GET /api/assets/{id}/preview?format=frames (loaded as
  preview.xyz). Shows a register-first hint when the file is not a registered
  asset.

Tests: server file-tree flag test; rstest gating on hasPreviewSidecar.
Full suite 1710 passed; UI typecheck/biome clean; production build OK.
ac-010 (live in-browser 3D render) still needs a browser session to verify.
… URL

Selection round-trips through the URL (?file=&fileKind=), which dropped the
asset id and the sidecar flag — so the molvis Preview tab never lit up even
though LeftPanel set them on the selection. Serialize/parse both in
useNavigationState. Verified live: selecting a sidecar-backed dataset now
shows Edit|Preview and molvis renders the trajectory from
/api/assets/{id}/preview?format=frames.
Replace the `workspace <TARGET>` god-group with a flat two-level tree. All
verbs (run/serve/monitor/explore/info/exec/shell/sync/push/pull) and noun
groups (project/experiment/runs/asset/target/session/config/mcp) register
directly on the top-level app. TARGET moves from a group positional to a
per-command `-t/--target` option (default cwd) via the new
`cli/_target.resolve_workspace_target` (shared resolver wrapping
workspace.target). `@name` now resolves against the cwd workspace registry.

Renames: exec-cmd→exec, upload→push, download→pull. Dedups init/target/runs
to one home each (drops the resources `target` duplicate; keeps the tested
top-level `target_cmd`). `run`'s old `--target` (compute target) →
`--compute-target`, and `--time` drops its `-t` short flag so `-t` is the
workspace target everywhere.

Implements specs cli-redesign-0{1,2,3,4}. 113 cli tests pass; ruff + ty clean
on src/molexp/cli/.
- CI lint job now runs the exact hook set from .pre-commit-config.yaml
  instead of duplicated ruff/ty steps that could drift.
- ty stays advisory in BOTH (codebase has ~331 ty diagnostics; was already
  continue-on-error in CI — now the pre-commit hook matches via 'ty check || true').
- Exclude generated OpenAPI client (ui/src/api/generated/) from all hooks.
- Apply the resulting ruff-format/eof fixes (test_help_surface.py, moko.svg).
ci: align CI lint with pre-commit config
docs: prefer shadcn/ui for frontend components
@Roy-Kid
Roy-Kid merged commit 28ef327 into master Jun 1, 2026
7 checks passed
Roy-Kid added a commit to Roy-Kid/molab that referenced this pull request Jul 4, 2026
…-execution-01)

Slice 01/03 of the P2.1 guarded-execution chain (integration.md §6.1, §8.3).
New molexp.harness.actions subpackage — the coordination-layer "Act" seam a
granted ChangeProposal will dispatch through:

- ProposalExecutor.dispatch(ctx, proposal) — resolves the handler for the
  proposal's HighRiskOp and records the attempt.
- ChangeActionHandler Protocol + ChangeActionRegistry (HighRiskOp → handler,
  modeled on InMemoryCapabilityRegistry).
- assert_within_affected_scope — the §8.2 binding-scope guard.
- ProposalActionRecorder — traceable tool_called/tool_completed/tool_failed
  events (reuses the existing EventType values — NO widening), each carrying
  proposal_id + high_risk_op (invariant MolCrafts#7).

Contracts: unhandled op → UnhandledHighRiskOpError (loud, no event, §10 MolCrafts#10);
handler success → executed; handler exception (incl. OutOfAffectedScopeError)
→ recorded status=failed + tool_failed, not raised (§8.3). No schema change
(ProposalOutcome already carries executed/failed). Stub handler lives in tests
— no concrete handler ships in src/. Gate untouched (slice 03).

Full suite 2695 passed; import-guard green (import molexp.harness stays free of
pydantic_ai/pydantic_graph).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant