feat(bundle-sdk-py): Python SDK for ai.nimblebrain/host-resources (phase 2b)#268
Merged
Conversation
…ase 2b) Bundle-side wrapper for the host-resources extension shipped in PR #263. Bundle authors write `await host(ctx).read("files://fl_abc")` instead of hand-rolling the JSON-RPC call shape. ## New package: `nimblebrain-bundle-sdk` Lives at `packages/bundle-sdk-py/`. First top-level `packages/` directory in this monorepo. Python 3.11+, fastmcp >=3.0, mcp >=1.27. Standard hatchling/ruff/ty/pytest layout matching synapse-research conventions. ## API - `host(ctx)` — factory returning a `HostResources` handle wrapping the bundle's `Context`. - `HostResources.available` — capability probe. Reads `ClientCapabilities.extensions["ai.nimblebrain/host-resources"]` (the spec-blessed location), falls back to `experimental` for older platforms. Returns False on any malformed shape so a buggy host can't crash the bundle's probe. - `HostResources.supports_scheme(scheme)` — per-scheme allowlist check. - `HostResources.read(uri)` — wraps `ai.nimblebrain/resources/read`, returns the MCP-standard `ReadResourceResult`. - `HostResources.list(*, scheme=, mime_type=, tags=)` — wraps `ai.nimblebrain/resources/list` with the platform's `_meta.filter` unwrap convention. - `HostCapabilityMissing` exception — supports the Level-C fallback pattern (catch + return a structured tool error teaching the agent to retry with inline content). - Error-code constants (`RATE_LIMITED = -32004`, `RESPONSE_TOO_LARGE = -32005`, etc.) so bundles match on names, not magic numbers. ## Implementation notes - `ServerSession.send_request` is typed against `ServerRequest`, a closed Pydantic `RootModel` union of the spec's known methods. Our custom-method requests aren't in that union. At runtime, `send_request` only calls `request.model_dump()` — any Pydantic model with the right shape works. We pass typed BaseModel subclasses and `cast` them to `ServerRequest` at the call site (static-type accommodation only). - `_meta` field name has a leading underscore (MCP spec convention). Pydantic forbids leading-underscore attribute names; we use `Field(alias="_meta")` with `populate_by_name=True` so the wire shape stays correct while the Python attribute is `meta`. ## Tests 14 tests, all passing. Covers: - Capability detection: extensions, experimental fallback, read disabled, malformed shape. - Scheme allowlist probes. - `read` dispatch shape (method name + params). - `list` dispatch shape: no filter → empty params; mime_type filter → `_meta.filter.mimeType`; tags filter → `_meta.filter.tags`; combined filters. - `HostCapabilityMissing` raised locally without wire call. ## Release tooling `.github/workflows/release-bundle-sdk-py.yml` triggers on `bundle-sdk-py/v*` tags (distinct from the platform's `v*`). Verifies the tag matches `pyproject.toml`'s version, builds wheel + sdist via `uv build`, publishes to PyPI via Trusted Publishing (OIDC — no long-lived API token), creates a GitHub Release. First-time PyPI setup (one-time per package, by an operator) is documented in the workflow header. ## Platform verify `bun run verify` green — the new `packages/` directory doesn't interact with the platform build path. Static checks, unit, integration, smoke all clean.
4 tasks
…und 11 on #268) Three real issues from QA round 11: ## Polish - **`_HostResourcesListFilter`'s `populate_by_name = True` was dead config + lying docstring.** The class declared the Pydantic config and the comment promised snake_case ↔ camelCase aliasing — but no `Field(alias=...)` was ever wired. Serialization actually went through a hand-rolled `to_wire()` dict-build. Removed the dead config and rewrote the docstring to describe what the code actually does: explicit dict-build is simpler than alias plumbing for an internal model whose only caller is `HostResources.list`. - **`supports_scheme` defensive branch was untested.** The `isinstance(schemes, list)` guard in `host.py:146` protects the bundle from a host that advertises `schemes` as a non-list. New test mirrors `test_available_false_on_malformed_shape` — pins the guard so a future "simplification" fails CI. - **CI lint scope didn't include `tests/`.** The release workflow ran `ruff check src/` only, which let an I001 import-order violation in `tests/test_host.py` ship to QA. Widened to `src/ tests/` for both lint and format-check. Release-time lint failures block a publish, so catching scope drift on PR is strictly better. ## Verify 15 unit (+1 new defensive test) + ruff clean on both src/ and tests/.
… round 12 on #268) ## Per-method capability gates (suggestion 1) `HostResources.list()` previously gated on `available`, which only reads `cap["read"]["enabled"]`. A future host that advertises `list.enabled=true` with `read.enabled=false` would have caused `list()` to falsely raise HostCapabilityMissing — the SDK was silently baking in v1's lockstep assumption. Fix: - Add `_method_enabled(method)` helper that gates per-method. - `read()` gates on `_method_enabled("read")`. - `list()` gates on `_method_enabled("list")`. - `available` (the common-case probe for `read`) stays as-is but docstring now explicitly says it's the read-only probe. - New `list_available` property mirrors `available` for the list path. v1 hosts move both flags in lockstep so it's a no-op today, but the shape allows independent flags and the SDK should follow the shape, not the v1 convention. ## mcp upper bound (suggestion 3) The `cast(ServerRequest, request)` shim in host.py relies on `BaseSession.send_request` calling only `.model_dump()` on the input — behavior verified against mcp 1.27.x but not contractually guaranteed. A 2.0 release could plausibly tighten input validation and break us at runtime (not import). Pinned `mcp>=1.27.0,<2.0.0` and documented the contract dependency inline at the cast site + on the dep itself. ## Suggestion 2 (docstring drift) Naturally resolved by suggestion 1's per-method gates: each method's docstring now precisely names the flag it checks (`read.enabled` vs `list.enabled`). ## Tests 21 passing (was 15, +6 new): - list_available reflects list.enabled independently of read.enabled - available reflects read.enabled independently of list.enabled - list_available defaults False when no caps - list_available False on malformed list block - list() raises when only read.enabled - read() raises when only list.enabled Lint + format clean on src/ and tests/.
mgoldsborough
added a commit
to NimbleBrainInc/synapse-research
that referenced
this pull request
May 22, 2026
PR NimbleBrainInc/nimblebrain#268 merged as 852cbdd. The previous git ref pointed at the SDK PR branch (89cd28f) which was deleted on merge — the ref still resolves via github.com's commit-keyed fetch, but tracking a deleted branch's tip is confusing. Bump to the squash-merge commit on main. Still git-sourced, not PyPI; the actual `bundle-sdk-py/v0.1.0` PyPI publish requires the one-time Trusted Publisher setup. Once that lands, drop the `[tool.uv.sources]` block entirely and let the version pin in `[project.dependencies]` resolve through PyPI.
mgoldsborough
added a commit
to NimbleBrainInc/synapse-research
that referenced
this pull request
May 23, 2026
* feat: accept seed_uri via host-resources SDK (phase 2c) Add an optional `seed_uri` parameter to `start_research`. When set, the server resolves the URI via the `ai.nimblebrain/host-resources` extension (using `nimblebrain-bundle-sdk`'s `host(ctx).read()`) and prepends the file content to the research prompt. Closes the original production bug — synapse-research can now anchor research on a workspace file (brokers.csv, transcript, doc) the agent points it at, instead of either receiving content inlined in the query (large + ugly) or doing web research from scratch (the bug we hit). ## Behaviour - `seed_uri="files://fl_abc"`: server reads the file, prepends content under a `## Workspace seed` header in the prompt, records `seed_content_chars` on the `research_run` entity. - Seeds above 400 KB are truncated with a visible marker so the model and the human reader both see the cut happened. - Binary resources (no `text` field) are refused with a clear error telling the agent to extract text upstream. - Hosts that don't advertise the host-resources extension surface a `HostCapabilityMissing` error naming the capability — the agent knows to retry with content inline via its own file-reading tool (the Level-C fallback pattern from the host-resources design). ## SDK source Pins `nimblebrain-bundle-sdk>=0.1.0`. Until the SDK lands on PyPI, the `[tool.uv.sources]` block sources from the local path at `../../products/nimblebrain/code/packages/bundle-sdk-py/` (relative to this pyproject.toml — requires the `hq` meta-repo cloned alongside synapse-research, the standard NimbleBrain dev layout). Drop that block once the package is on PyPI. **Ordering:** this PR depends on `NimbleBrainInc/nimblebrain#268` (the SDK package) merging first. Otherwise the local path doesn't exist and `uv sync` fails. ## Tests 48 passing (43 existing + 5 new). The new file covers: - happy path: seed content reaches the worker's gpt-researcher prompt - entity records `seed_content_chars` correctly - Level-C: `HostCapabilityMissing` surfaces with the capability name - binary refusal: clear error directing the agent to extract upstream - backward-compat: omitting `seed_uri` is a no-op - SDK importability smoke Tests mock at the `host()` factory boundary because the Python `mcp.client.ClientSession` rejects custom-method server→client requests (its `ServerRequest` union is closed). Wire-shape validation lives in the SDK's own unit tests and the platform's TS-side handler tests, both of which already exist. * ci: source bundle-sdk-py from git instead of local path synapse-research CI clones only its own repo — the local-path `tool.uv.sources` (`../../products/nimblebrain/code/packages/bundle-sdk-py`) resolves on developer machines with the `hq` meta-repo cloned alongside, but breaks in CI where the parent path doesn't exist. Switch to a git source pinned to a specific commit on the SDK PR branch (`nimblebrain@89cd28f`). Reproducible builds, works in CI without a meta-repo clone, and bumps explicitly when the SDK ships a new pre-1.0 revision. Drop this block once `nimblebrain-bundle-sdk` is on PyPI; the version pin in `[project.dependencies]` resolves through PyPI from then on. Until then, contributors editing the SDK locally should `uv pip install -e <path>` against their checkout. * feat: add seed_data parameter as universal inline fallback The previous version added `seed_uri` (host-resources-extension read) but left a real gap: hosts without the extension had nowhere to put seed content. The error message even told the agent to "pass content inline via `seed_data`" — except `seed_data` didn't exist as a parameter. Fixing that. ## Changes `start_research` now takes both `seed_uri` and `seed_data`, mutually exclusive: - `seed_uri`: host reads via `ai.nimblebrain/host-resources`. Preferred when available — the agent doesn't pay context budget on the file bytes. - `seed_data`: raw text passed inline by the agent. Universal fallback that works on every host. Required for hosts without the extension. When `seed_uri` is set on a host that doesn't advertise the extension, the tool now returns a `ValueError` whose message names both the missing capability AND the specific retry shape (`seed_data=<file contents>`). The previous error pointed at a non-existent parameter — a Level-C signal that wasn't actionable. Passing both `seed_uri` and `seed_data` is rejected as ambiguous rather than picking one; the agent probably confused the two paths. ## Tests 8 passing in tests/test_seed_uri.py (+3 new): - `seed_data` inline reaches the worker prompt - `seed_uri` + `seed_data` together → mutex error - Capability-missing error tells the agent to retry with `seed_data` specifically (not just "pass content inline") All 51 tests (43 existing + 8 in test_seed_uri.py) pass. * style: ruff format * fix(manifest): briefing.priority "normal" → "medium" The platform's host-manifest schema constrains `briefing.priority` to `["high", "medium", "low"]` (host-manifest.schema.json:153). The existing `"normal"` value was never valid against this enum — it worked previously because the platform didn't enforce the schema strictly. Recent installs fail with: Bundle "@nimblebraininc/synapse-research" has an invalid _meta["ai.nimblebrain/host"] block: ai.nimblebrain/host/briefing/priority: must be equal to one of the allowed values. Refusing to install. Switching to `"medium"` — it's the middle bucket, matching the prior intent (default-ish priority for this app's briefing facets). Unrelated to the Phase 2c work in this PR; folded in because it blocks local testing of any synapse-research install against current platform main. * chore: bump bundle-sdk-py source to post-merge SHA on platform main PR NimbleBrainInc/nimblebrain#268 merged as 852cbdd. The previous git ref pointed at the SDK PR branch (89cd28f) which was deleted on merge — the ref still resolves via github.com's commit-keyed fetch, but tracking a deleted branch's tip is confusing. Bump to the squash-merge commit on main. Still git-sourced, not PyPI; the actual `bundle-sdk-py/v0.1.0` PyPI publish requires the one-time Trusted Publisher setup. Once that lands, drop the `[tool.uv.sources]` block entirely and let the version pin in `[project.dependencies]` resolve through PyPI. * chore: switch nimblebrain-bundle-sdk source from git to PyPI nimblebrain-bundle-sdk v0.1.0 is now on PyPI: https://pypi.org/project/nimblebrain-bundle-sdk/0.1.0/ Drop the `[tool.uv.sources]` git-source override. The `>=0.1.0` pin in `[project.dependencies]` now resolves through PyPI, which means fresh clones (devs, CI runners) install the SDK without needing the `hq` meta-repo cloned alongside or a specific git commit fetched. uv.lock updated to reflect the registry source. 51 tests still green. * fix(seed): scheme probe + empty-text guard + truncation test (QA round 13 on #8) Three substantive fixes from QA review: ## Truncation test (Critical #4) `_SEED_MAX_CHARS = 400_000` was implemented without a test exercising the slicing path or the marker shape. Added `test_seed_truncation_emits_marker` that constructs a seed >cap with sentinel head/tail strings, asserts the head survives the cut, the tail is dropped, and the marker carries both the cap and the actual length in its formatted-number form. Future refactors that touch the cap or the marker text now fail loudly. ## Empty-text resource distinct from binary (Suggestion #1) `if not text:` matched both `None` (binary) and `""` (empty text). A legitimately empty workspace file was reported as binary with a misleading "extract text upstream" recovery hint. Tightened to `if text is None:` for the binary branch and added a dedicated empty-text branch whose error tells the agent the file is actually empty (verify contents, or omit `seed_uri`). New test pins this distinction. ## Scheme probe before read (Suggestion #3) `_resolve_seed_uri` now calls `h.supports_scheme("files")` after the availability check. The platform would otherwise return `-32602 Invalid params` for an unsupported scheme, which the agent sees as a generic wire error. Routing it through the same Level-C retry hint (pass `seed_data` inline) gives the agent an actionable recovery path. Not load-bearing today (the platform always supports `files://`), but cheap insurance and exercises the SDK's `supports_scheme()` surface. ## Tests 54 passing (43 existing + 11 in test_seed_uri.py, +3 new): - test_seed_truncation_emits_marker - test_seed_uri_empty_text_distinct_from_binary - test_seed_uri_scheme_not_supported All existing fixtures (`seeded_host`, `unavailable_host`, `binary_host`) grew a `supports_scheme()` method to match the new SDK probe. Lint + format clean on src/ and tests/. * fix: untrack accidentally-staged .claude/worktrees submodule pointer QA reviewer's worktree at `.claude/worktrees/feat-host-resources-sdk-adoption` got staged as an embedded repo in the previous commit because `.claude/` wasn't in `.gitignore`. Untrack the pointer + add the directory to gitignore so future QA worktrees don't reintroduce it. * fix(gitignore): restore .tasks/ + actually add .claude/ (fixes 9a99def) Commit 9a99def corrupted .gitignore by appending `.claude/` via `echo >>` to a file whose last line had no trailing newline. The concatenation produced: .tasks/.claude/ which broke BOTH intended behaviours: - `.tasks/foo` was no longer ignored (`/implement` scratch leaked) - `.claude/worktrees/foo` was never ignored (QA worktree submodule pointers can still be re-staged — the bug 9a99def was meant to fix) The only path that was newly ignored — `.tasks/.claude/` — doesn't exist in this repo. Split into two distinct lines with proper newline terminators. Verified with `git check-ignore -v`: .gitignore:28:.tasks/ .tasks/foo .gitignore:31:.claude/ .claude/worktrees/foo --------- Co-authored-by: Mathew Goldsborough <1759329+mgoldsborough@users.noreply.github.com>
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.
Phase 2b of the host-resources roadmap. Phase 1 (#262) advertised the capability and gated installs; Phase 2a (#263) wired the platform-side handlers and resolver. This PR ships the bundle-side wrapper: bundle authors write `await host(ctx).read("files://fl_abc")` instead of hand-rolling the JSON-RPC call.
Unblocks Phase 2c — the synapse-research adoption that closes the original production bug.
New package
`packages/bundle-sdk-py/` — first top-level `packages/` in this monorepo. PyPI name: `nimblebrain-bundle-sdk`. Python 3.11+. Pinned to `fastmcp>=3.0` and `mcp>=1.27`, matching what synapse-research and other production bundles use.
API surface
```python
from fastmcp import Context
from nimblebrain_bundle_sdk import host, HostCapabilityMissing
@mcp.tool
async def my_tool(seed_uri: str | None, seed_data: str | None, ctx: Context):
h = host(ctx)
if seed_uri and h.available:
result = await h.read(seed_uri)
return result.contents[0].text
elif seed_data:
return seed_data
elif seed_uri:
# Level-C fallback: capability missing, teach the agent to retry inline.
raise HostCapabilityMissing("ai.nimblebrain/host-resources")
```
Implementation notes
Tests
14 tests, all passing. Covers capability detection (extensions + experimental + malformed shapes), scheme probes, read/list dispatch shapes (params, `_meta.filter` unwrap), and `HostCapabilityMissing` raising locally without a wire call.
Release tooling
`.github/workflows/release-bundle-sdk-py.yml` triggers on `bundle-sdk-py/v*` tags (distinct from the platform's `v*`). Verifies tag matches `pyproject.toml` version, builds via `uv build`, publishes to PyPI via Trusted Publishing (OIDC — no API token), creates a GitHub Release.
One-time PyPI setup is required before the first release (operator action, documented in the workflow header):
After that, every `bundle-sdk-py/v0.x.y` tag publishes automatically.
Test plan
What's next