Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,9 @@ claude/
!python/synapse/_vendor/**
python/synapse/_vendor/**/__pycache__/
python/synapse/_vendor/**/*.pyc

# SCOUT reconnaissance artifacts (read-only investigation output)
.scout/

# FORGE build-orchestration artifacts (run logs, specs)
.forge/
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ setx ANTHROPIC_API_KEY "sk-ant-..."

Launch a fresh Houdini after running `setx` — the new value only reaches processes started after.

Or run the helper **`set_anthropic_key.bat`** at the repo root: it prompts for the key, persists it with `setx`, and reminds you to relaunch Houdini — so you don't have to remember the command or the system-vs-shell-scope gotcha.

**Forward-compat — `hou.secure`.** When SideFX ships a secure-credentials API in a future Houdini release, SYNAPSE's auth resolver picks it up automatically. Confirmed **not present** in Houdini 21.0.671 (`dir(hou)` only exposes `secureSelectionOption`). No action needed today.

**You're good if:** in Houdini's Python Shell, `import os; print(bool(os.environ.get('ANTHROPIC_API_KEY')))` prints `True`.
Expand Down Expand Up @@ -239,14 +241,49 @@ daemon.stop()
| **Perception channel — `TopsEventBridge`** (Spike 3.1) | Scaffolded. 47 tests across basic + hostile. Standalone mode only. Live PDG cook lands at Mile 5. |
| **Perception channel — `SceneLoadBridge`** (Spike 3.2) | Scaffolded. 24 tests across basic + hostile. Composes a `TopsEventBridge`; auto-warm on `hou.hipFile.AfterLoad`. Live integration at Mile 5. |
| **Tools ported through the Dispatcher** | **1** — `synapse_inspect_stage` (flat `/stage` AST). |
| **Tools still on the Sprint 2 WebSocket path** | **104** — registry tools working in production, awaiting port. (Plus 6 group-info knowledge tools that don't need porting — they serve local content without Houdini.) |
| **Tools still on the Sprint 2 WebSocket path** | **108** — registry tools working in production, awaiting port (104 → 108 with the v5.9.0 SCOUT→FORGE additions below). (Plus 6 group-info knowledge tools that don't need porting — they serve local content without Houdini.) |

The port pattern is mechanical and documented in `docs/crucible_protocol.md` + the `spike(1)` commit message. Every legacy tool gets:

1. A pure-Python function under `synapse.cognitive.tools.<name>` (zero `hou` imports).
2. A schema dict (description + JSON Schema) registered alongside the function.
3. The WS adapter branch in `mcp_server.py` swapped from `synapse_inspect_stage`-style direct dispatch to `dispatcher.execute('<name>', kwargs)`.

### v5.9.0 — SCOUT → FORGE: 7 verified capabilities

A read-only **SCOUT** recon cross-referenced the Houdini 21.0.671 capability surface against the live tool registry, surfaced 7 opportunities, and **V1-verified every one against the exact target build** (21.0.671 `hython`) before any code was written. A **FORGE** MOE agent team then built and unit-tested them, with **CRUCIBLE** adversarial review gating the merge. Registry **104 → 108 tools**:

- `houdini_set_payload_loadstate` — USD payload load/unload + activation
- `houdini_create_point_instancer` — `UsdGeom.PointInstancer` authoring
- `houdini_shot_render_ready` — shot-template composite orchestrator
- `cops_create_copnet` — modern Copernicus `copnet` (distinct from the legacy `cop2net` the existing COPs tools build on)
- `houdini_reference_usd` + `karma_visible`/`purpose`/`kind` — non-clobbering Karma-visibility metadata on import (completes the BL-008 advisory-only partial)
- `houdini_modify_usd_prim` + `instanceable`
- branch-aware, path-keyed upstream Karma-LOP discovery in the render walk

Plus bridge/panel hardening: read-only tool failures surface as JSON-RPC errors instead of success-with-`isError`, and the panel resolves the Anthropic key through the canonical auth layer with an actionable "set it + relaunch" message.

```mermaid
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1e293b','primaryTextColor':'#f1f5f9','primaryBorderColor':'#0f172a','lineColor':'#f59e0b','secondaryColor':'#334155','tertiaryColor':'#475569'}}}%%
flowchart LR
S["SCOUT<br/>read-only recon<br/>RAG + codebase"] -->|7 opportunities<br/>V1-verified on 21.0.671| F["FORGE<br/>MOE agent team<br/>build + unit test"]
F -->|diff| R["CRUCIBLE<br/>adversarial review"]
R -->|fix-forward| F
R -->|108 tools, green| P["PR 4<br/>shipped"]
classDef scout fill:#1e293b,stroke:#f59e0b,color:#f1f5f9
classDef forge fill:#1e293b,stroke:#3b82f6,color:#f1f5f9
classDef cruc fill:#1e293b,stroke:#ef4444,color:#f1f5f9
classDef gate fill:#334155,stroke:#22c55e,color:#f1f5f9
class S scout
class F forge
class R cruc
class P gate
```

Behavioral verification (Karma cook of `copnet`, EXR landing, USD editableStage round-trips) is deferred to a live 21.0.671 session.

---

### Sprint 3 progress — Mile 4 of 6 closed

```mermaid
Expand Down
12 changes: 10 additions & 2 deletions houdini/python_panels/synapse_panel.pypanel
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,17 @@ class _FallbackClaudeWorker(QtCore.QThread):

def run(self):
try:
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
try:
from synapse.host.auth import get_anthropic_api_key
api_key = get_anthropic_api_key() or ""
except Exception:
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
if not api_key:
self.stream_error.emit("ANTHROPIC_API_KEY not set in environment")
self.stream_error.emit(
'No Anthropic API key found. Set it at the SYSTEM level and '
'relaunch Houdini: setx ANTHROPIC_API_KEY "sk-ant-..." '
"(a terminal-scoped `set` won't carry into Houdini on Windows)."
)
return

body = {
Expand Down
2 changes: 2 additions & 0 deletions mcp_tools_cops.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
TOOL_NAMES = [
# Foundation
"cops_create_network",
"cops_create_copnet",
"cops_create_node",
"cops_connect",
"cops_set_opencl",
Expand Down Expand Up @@ -55,6 +56,7 @@
# Dispatch entries for this group
DISPATCH_KEYS = {
"cops_create_network": ("cops_create_network", "identity"),
"cops_create_copnet": ("cops_create_copnet", "identity"),
"cops_create_node": ("cops_create_node", "identity"),
"cops_connect": ("cops_connect", "identity"),
"cops_set_opencl": ("cops_set_opencl", "identity"),
Expand Down
8 changes: 7 additions & 1 deletion mcp_tools_usd.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"houdini_create_usd_prim",
"houdini_modify_usd_prim",
"houdini_reference_usd",
"houdini_set_payload_loadstate",
"houdini_create_point_instancer",
"houdini_shot_render_ready",
"houdini_query_prims",
"houdini_manage_variant_set",
"houdini_manage_collection",
Expand All @@ -47,8 +50,11 @@
"houdini_get_usd_attribute": ("get_usd_attribute", "filter_keys:node,prim_path,attribute_name"),
"houdini_set_usd_attribute": ("set_usd_attribute", "filter_keys:node,prim_path,attribute_name,value"),
"houdini_create_usd_prim": ("create_usd_prim", "filter_keys:node,prim_path,prim_type"),
"houdini_modify_usd_prim": ("modify_usd_prim", "filter_keys:node,prim_path,kind,purpose,active"),
"houdini_modify_usd_prim": ("modify_usd_prim", "filter_keys:node,prim_path,kind,purpose,active,instanceable"),
"houdini_reference_usd": ("reference_usd", "identity"),
"houdini_set_payload_loadstate": ("set_payload_loadstate", "identity"),
"houdini_create_point_instancer": ("create_point_instancer", "identity"),
"houdini_shot_render_ready": ("shot_render_ready", "identity"),
"houdini_query_prims": ("query_prims", "identity"),
"houdini_manage_variant_set": ("manage_variant_set", "identity"),
"houdini_manage_collection": ("manage_collection", "identity"),
Expand Down
2 changes: 1 addition & 1 deletion python/synapse/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@


__title__ = "Synapse"
__version__ = "5.8.0"
__version__ = "5.9.0"
__author__ = "Joe Ibrahim"
__license__ = "MIT"
__product__ = "Synapse - AI-Houdini Bridge"
Expand Down
4 changes: 4 additions & 0 deletions python/synapse/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ class CommandType(Enum):
GET_STAGE_INFO = "get_stage_info"
SET_USD_ATTRIBUTE = "set_usd_attribute"
GET_USD_ATTRIBUTE = "get_usd_attribute"
SET_PAYLOAD_LOADSTATE = "set_payload_loadstate"
CREATE_POINT_INSTANCER = "create_point_instancer"
SHOT_RENDER_READY = "shot_render_ready"
QUERY_PRIMS = "query_prims"
MANAGE_VARIANT_SET = "manage_variant_set"
MANAGE_COLLECTION = "manage_collection"
Expand Down Expand Up @@ -101,6 +104,7 @@ class CommandType(Enum):

# Copernicus (COPs) — Foundation
COPS_CREATE_NETWORK = "cops_create_network"
COPS_CREATE_COPNET = "cops_create_copnet"
COPS_CREATE_NODE = "cops_create_node"
COPS_CONNECT = "cops_connect"
COPS_SET_OPENCL = "cops_set_opencl"
Expand Down
61 changes: 59 additions & 2 deletions python/synapse/mcp/_tool_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,15 @@ def _delete_node_payload(args: dict) -> dict:
False, True, False),

("houdini_modify_usd_prim", "modify_usd_prim",
_filter_keys(("node", "prim_path", "kind", "purpose", "active")),
"Modify USD prim metadata: kind, purpose, or active state.",
_filter_keys(("node", "prim_path", "kind", "purpose", "active", "instanceable")),
"Modify USD prim metadata: kind, purpose, active state, or instanceable flag.",
{"type": "object", "properties": {
"node": {"type": "string", "description": "LOP node to wire after (optional)"},
"prim_path": {"type": "string", "description": "USD prim path"},
"kind": {"type": "string", "description": "Model kind"},
"purpose": {"type": "string", "description": "Prim purpose"},
"active": {"type": "boolean", "description": "Whether the prim is active"},
"instanceable": {"type": "boolean", "description": "Set the prim's instanceable flag"},
}, "required": ["prim_path"]},
False, True, False),

Expand Down Expand Up @@ -522,9 +523,56 @@ def _delete_node_payload(args: dict) -> dict:
"mode": {"type": "string", "enum": ["reference", "payload", "sublayer"],
"description": "Import mode: reference (default), payload (deferred load), or sublayer (most Karma-compatible)"},
"parent": {"type": "string", "description": "Parent LOP network path"},
"karma_visible": {"type": "boolean", "description": "Author purpose/kind on the referenced prim for Karma visibility (default: true)"},
"purpose": {"type": "string", "description": "USD purpose to author non-clobberingly (default: default)"},
"kind": {"type": "string", "description": "USD model kind to author non-clobberingly (default: component)"},
}, "required": ["file"]},
False, True, False),

("houdini_set_payload_loadstate", "set_payload_loadstate", _identity,
"Control USD payload load state and prim activation. Load/unload a payload by "
"prim path and/or toggle the prim's active flag. Use to defer-load or release "
"heavy referenced assets.",
{"type": "object", "properties": {
"node": {"type": "string", "description": "LOP node to wire after (optional)"},
"prim_path": {"type": "string", "description": "USD prim path carrying the payload"},
"action": {"type": "string", "enum": ["load", "unload"],
"description": "Load or unload the payload (optional)"},
"active": {"type": "boolean", "description": "Set prim active/inactive (optional)"},
}, "required": ["prim_path"]},
False, True, False),

("houdini_create_point_instancer", "create_point_instancer", _identity,
"Author a UsdGeom.PointInstancer: scatter prototype prims across positions. "
"Minimal valid setup -- defines the instancer, sets the prototypes relationship, "
"protoIndices (defaults to zeros), and positions.",
{"type": "object", "properties": {
"node": {"type": "string", "description": "LOP node to wire after (optional)"},
"prim_path": {"type": "string", "description": "USD prim path for the PointInstancer"},
"prototypes": {"type": "array", "items": {"type": "string"},
"description": "Prototype prim paths to instance"},
"positions": {"type": "array", "items": {"type": "array", "items": {"type": "number"}},
"description": "Instance positions as [[x,y,z], ...]"},
}, "required": ["prim_path"]},
False, True, False),
Comment on lines +545 to +557
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Verify that optional prototypes and positions is intentional.

The description states "Minimal valid setup -- defines the instancer, sets the prototypes relationship, protoIndices (defaults to zeros), and positions", which implies these fields will be set. However, the schema makes both prototypes and positions optional (only prim_path is required).

Clarify what happens when these fields are not provided:

  • Does the handler create an empty point instancer?
  • Are there sensible defaults applied?
  • Should the description be updated to reflect that these are optional?

Run the following to verify the handler implementation:

#!/bin/bash
# Search for the create_point_instancer handler implementation
rg -n -A 20 'def _handle_create_point_instancer' --type=py
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/synapse/mcp/_tool_registry.py` around lines 545 - 557, The schema for
the "houdini_create_point_instancer" / "create_point_instancer" action marks
"prototypes" and "positions" as optional but the description implies they will
be set; inspect the handler function _handle_create_point_instancer to determine
actual behavior (does it create an empty instancer, apply protoIndices defaults,
or fill positions) and then make the schema and description consistent: either
require "prototypes" and "positions" in the schema if the handler needs them, or
update the description to state that these fields are optional and describe the
defaults/empty-instancer behavior that the handler implements (and if necessary,
modify _handle_create_point_instancer to apply sensible defaults such as empty
prototypes and zeroed protoIndices when fields are missing).


("houdini_shot_render_ready", "shot_render_ready", _identity,
"Composite orchestrator: get a shot render-ready in one call. Runs "
"create_textured_material -> solaris_assemble_chain -> safe_render in sequence, "
"threading outputs, and returns a per-step summary with any errors. Orchestrates "
"existing primitives -- does not re-implement them.",
{"type": "object", "properties": {
"diffuse_map": {"type": "string", "description": "Diffuse/albedo texture for the material step (optional)"},
"material_name": {"type": "string", "description": "Material name (optional)"},
"geo_pattern": {"type": "string", "description": "Geometry prim pattern to assign the material to (optional)"},
"parent": {"type": "string", "description": "LOP network path for assembly (default: /stage)"},
"rop_path": {"type": "string", "description": "Render ROP path (auto-discovers if omitted)"},
"width": {"type": "integer", "description": "Render width override"},
"height": {"type": "integer", "description": "Render height override"},
"skip_render": {"type": "boolean", "description": "Assemble only, skip the render step (default: false)"},
}, "required": []},
False, True, False),

("houdini_query_prims", "query_prims", _identity,
"Query USD stage prims with filtering by type, purpose, and name pattern. "
"Returns matching prims with their paths, types, and metadata.",
Expand Down Expand Up @@ -1019,6 +1067,15 @@ def _delete_node_payload(args: dict) -> dict:
}, "required": []},
False, True, False),

("cops_create_copnet", "cops_create_copnet", _identity,
"Create a modern Copernicus 'copnet' network (H21 Copernicus, distinct from legacy cop2net).",
{"type": "object", "properties": {
"parent": {"type": "string", "description": "Parent node path (default: /obj)"},
"name": {"type": "string", "description": "Network name (default: copnet)"},
"starter": {"type": "string", "description": "Optional COP node type to create inside the new copnet"},
}, "required": []},
False, True, False),

("cops_create_node", "cops_create_node", _identity,
"Create a COP node inside a COP network.",
{"type": "object", "properties": {
Expand Down
21 changes: 16 additions & 5 deletions python/synapse/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ def _dumps(obj) -> bytes:

logger = logging.getLogger("synapse.mcp")


def _isError_text(result: dict) -> str:
"""Extract a human-readable error message from a dispatch_tool isError result."""
content = result.get("content", [])
if content and isinstance(content[0], dict):
return content[0].get("text", "Unknown error")
return "Unknown error"

# SSE event types
SSE_RENDER_PROGRESS = "synapse/render_progress"
SSE_GATE_REQUEST = "synapse/gate_request"
Expand Down Expand Up @@ -423,7 +431,13 @@ def _handle_tools_call(self, params: dict, session_id: Optional[str] = None) ->
)
except ImportError:
result = dispatch_tool(handler, tool_name, arguments)
if self._circuit_breaker and not (isinstance(result, dict) and result.get("isError")):
# Propagate tool errors to the JSON-RPC layer here too — read-only
# tools skip resilience, but a handler failure must still surface as
# a JSON-RPC error rather than a success result with isError buried
# inside (matches the non-read-only path below).
if isinstance(result, dict) and result.get("isError"):
raise JsonRpcError(INTERNAL_ERROR, _isError_text(result))
if self._circuit_breaker:
self._circuit_breaker.record_success()
return result

Expand Down Expand Up @@ -486,10 +500,7 @@ def _handle_tools_call(self, params: dict, session_id: Optional[str] = None) ->

# Propagate tool errors to JSON-RPC layer so MCP clients detect failures
if isinstance(result, dict) and result.get("isError"):
error_text = "Unknown error"
content = result.get("content", [])
if content and isinstance(content[0], dict):
error_text = content[0].get("text", error_text)
error_text = _isError_text(result)
# Only record infrastructure failures for circuit breaker
# (not user errors like "node not found")
if any(k in error_text.lower() for k in ("timeout", "main thread", "crashed", "unresponsive")):
Expand Down
1 change: 1 addition & 0 deletions python/synapse/panel/bridge_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ def _dispatch(*_args, **_kwargs):
from synapse.core.protocol import SynapseResponse
integrity_dict = result.integrity.to_dict() if result.integrity else {}
return SynapseResponse(
id=command.id,
success=False,
error="Bridge: {}".format(result.error or "Unknown error"),
data={"_integrity": integrity_dict},
Expand Down
17 changes: 14 additions & 3 deletions python/synapse/panel/claude_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
import http.client
import json
import logging
import os
import ssl
from typing import Optional

Expand Down Expand Up @@ -91,9 +90,21 @@ def get_messages(self) -> list[dict]:
def run(self) -> None:
"""Entry point executed on the background thread."""
try:
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
# Resolve via the canonical auth layer: hou.secure (where available)
# then the ANTHROPIC_API_KEY env var, whitespace-stripped, never
# raising. Avoids the old raw os.environ read that missed keys stored
# in hou.secure and surfaced a cryptic message.
from ..host.auth import get_anthropic_api_key
api_key = get_anthropic_api_key()
if not api_key:
self.stream_error.emit("ANTHROPIC_API_KEY not set in environment")
self.stream_error.emit(
"No Anthropic API key found. Set it at the SYSTEM level so "
"Houdini inherits it, then relaunch Houdini: "
'setx ANTHROPIC_API_KEY "sk-ant-..." '
"(a terminal-scoped `set` won't carry into Houdini on Windows). "
"On builds exposing hou.secure you can instead run, in Houdini's "
"Python shell: hou.secure.setPassword('synapse_anthropic', 'sk-ant-...')."
)
return

self._conversation_loop(api_key)
Expand Down
Loading
Loading