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
105 changes: 82 additions & 23 deletions backend/domain/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,8 +547,51 @@ def add_chat_node(
self.nodes[node_id] = node
if parent_id is not None:
self.connect(parent_id, node_id)
else:
self.adopt_pending_system_prompt(node_id)
return node

def adopt_pending_system_prompt(self, root_id: str) -> SceneEdge | None:
"""Connects an unattached system-prompt note to a new branch root.

plugins/system_prompt/ can create its note with nothing selected, so
that a prompt can be written BEFORE the conversation it governs -
the natural order, and the one the old "select a node first" rule
made impossible without a wasted send. Such a note starts with no
edges, and AgentDispatcher._resolve_branch_system_prompt only ever
looks for a note -> root edge, so the note would stay silently inert
until something drew that edge. This is that something: the first
parentless chat node - a new branch root - adopts it.

Deliberately narrow. It fires only for a node created with no
parent, only when exactly one unattached system-prompt note exists,
and only while that root has no system prompt of its own; anything
more ambiguous is left alone rather than guessed at. Returns the
edge it created, or None when it did nothing."""
root = self.nodes.get(root_id)
if root is None:
return None
sources = {edge.source for edge in self.edges.values()}
pending = [
node
for node in self.nodes.values()
if node.kind == "note"
and getattr(node.state, "is_system_prompt", False)
and node.id not in sources
]
if len(pending) != 1:
return None
already_attached = any(
edge.target == root_id
and (source := self.nodes.get(edge.source)) is not None
and source.kind == "note"
and getattr(source.state, "is_system_prompt", False)
for edge in self.edges.values()
)
if already_attached:
return None
return self.connect(pending[0].id, root_id)

def add_code_node(
self,
x: float,
Expand Down Expand Up @@ -621,7 +664,7 @@ def add_document_node(
see DocumentState's own docstring (backend/domain/node_states.py)
for those exact rules.
"""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
# Mirrors DocumentNode.__init__'s `(attachment_kind or
Expand All @@ -648,7 +691,8 @@ def add_document_node(
),
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def add_thinking_node(
Expand All @@ -674,7 +718,7 @@ def add_thinking_node(
there is no delete_thinking_node; deletion goes entirely through the
existing generic remove_nodes.
"""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
title = str(thinking_text)[:THINKING_TITLE_PREVIEW_LENGTH] or "Thinking"
Expand All @@ -687,7 +731,8 @@ def add_thinking_node(
content=str(thinking_text),
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def add_html_node(
Expand Down Expand Up @@ -716,7 +761,7 @@ def add_html_node(
thinking): there is no delete_html_node; deletion goes entirely
through the existing generic remove_nodes.
"""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
title = str(html_content)[:HTML_TITLE_PREVIEW_LENGTH] or "HTML"
Expand All @@ -730,7 +775,8 @@ def add_html_node(
state=HtmlState(),
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def set_html_splitter_state(self, node_id: str, value: float) -> None:
Expand Down Expand Up @@ -788,7 +834,7 @@ def add_image_node(
additionally evicts this node's image_assets entry so bytes never
outlive the node (see remove_nodes).
"""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
asset_id = f"img{uuid.uuid4().hex}"
Expand All @@ -804,7 +850,8 @@ def add_image_node(
state=ImageState(image_asset_id=asset_id),
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def get_image_asset(self, asset_id: str) -> tuple[bytes, str] | None:
Expand Down Expand Up @@ -838,7 +885,7 @@ def add_conversation_node(self, x: float, y: float, parent_id: str) -> SceneNode
thinking/html/image): there is no delete_conversation_node; deletion
goes entirely through the existing generic remove_nodes.
"""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
node = SceneNode(
Expand All @@ -849,7 +896,8 @@ def add_conversation_node(self, x: float, y: float, parent_id: str) -> SceneNode
kind="conversation",
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def append_conversation_user_message(self, node_id: str, text: str) -> SceneNode:
Expand Down Expand Up @@ -915,7 +963,7 @@ def add_web_research_node(self, x: float, y: float, parent_id: str) -> SceneNode
is no meaningful single preview string before a query has ever been
run). Content starts empty; the query text only lands once
start_web_research_run is called."""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
node = SceneNode(
Expand All @@ -927,7 +975,8 @@ def add_web_research_node(self, x: float, y: float, parent_id: str) -> SceneNode
state=WebResearchState(),
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def start_web_research_run(self, node_id: str, query: str) -> SceneNode:
Expand Down Expand Up @@ -1003,7 +1052,7 @@ def add_artifact_node(self, x: float, y: float, parent_id: str) -> SceneNode:
document has ever been drafted). artifact_content starts empty; the
document text only lands once complete_artifact_generation is
called."""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
node = SceneNode(
Expand All @@ -1015,7 +1064,8 @@ def add_artifact_node(self, x: float, y: float, parent_id: str) -> SceneNode:
state=ArtifactState(),
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def append_artifact_user_message(self, node_id: str, text: str) -> SceneNode:
Expand Down Expand Up @@ -1072,7 +1122,7 @@ def add_gitlink_node(self, x: float, y: float, parent_id: str) -> SceneNode:
invalid_parent_message for Gitlink, there is no unparented/root form
in the domain model). Title is always the fixed literal "Gitlink"
(mirrors conversation/web_research/artifact's own fixed titles)."""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
node = SceneNode(
Expand All @@ -1084,7 +1134,8 @@ def add_gitlink_node(self, x: float, y: float, parent_id: str) -> SceneNode:
state=GitlinkState(),
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def set_gitlink_local_root(self, node_id: str, local_root: str) -> SceneNode:
Expand Down Expand Up @@ -1319,7 +1370,7 @@ def add_code_sandbox_node(self, x: float, y: float, parent_id: str) -> SceneNode
sandbox directory name (VirtualEnvSandbox re-sanitizes it again on
its own side, but a short, already-safe id keeps the on-disk path
short and human-scannable)."""
if parent_id not in self.nodes:
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
node = SceneNode(
Expand All @@ -1331,7 +1382,8 @@ def add_code_sandbox_node(self, x: float, y: float, parent_id: str) -> SceneNode
state=CodeSandboxState(code_sandbox_sandbox_id=uuid.uuid4().hex[:12]),
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

def set_web_research_retain_to_knowledge(self, node_id: str, retain: bool) -> SceneNode:
Expand Down Expand Up @@ -1942,7 +1994,7 @@ def remove_nodes(self, node_ids: list[str]) -> None:
# -- ADR-014 stage 14.1: Plugin SDK node-creation primitive -------------

def add_plugin_node(
self, kind: str, x: float, y: float, parent_id: str, *,
self, kind: str, x: float, y: float, parent_id: str | None, *,
title: str = "", content: str = "", state: NodeState | None = None,
) -> SceneNode:
"""Generic node-creation primitive for ADR-014's Plugin SDK
Expand All @@ -1952,17 +2004,24 @@ def add_plugin_node(
parent. `kind` arrives here ALREADY namespaced as
f"{plugin_id}.{local_kind}" (backend/plugin_sdk.py's HostContext.
register_node_kind) - this method itself does no namespacing or
validation of its own, same "trust the caller, one required-parent
posture" contract as add_web_research_node/add_gitlink_node/etc."""
if parent_id not in self.nodes:
validation of its own, same "trust the caller" contract as
add_web_research_node/add_gitlink_node/etc.

`parent_id=None` creates the node UNCONNECTED, at the given x/y.
That is the one deliberate difference from its add_X_node siblings,
and it is what a plugin registered with requires_parent=False needs:
such a plugin is creatable on an empty canvas, where by definition
there is no parent to attach to."""
if parent_id is not None and parent_id not in self.nodes:
raise SceneError(f"unknown parent node: {parent_id}")
node_id = f"n{next(self._counter)}"
node = SceneNode(
id=node_id, x=float(x), y=float(y),
title=str(title) or kind, kind=str(kind), content=str(content), state=state,
)
self.nodes[node_id] = node
self.connect(parent_id, node_id)
if parent_id is not None:
self.connect(parent_id, node_id)
return node

# -- edges -------------------------------------------------------------
Expand Down
42 changes: 29 additions & 13 deletions backend/plugin_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -340,14 +340,26 @@ class PluginRunContext:

plugin_id: str
notifications: NotificationState
# Where a PARENTLESS node should be created, in scene coordinates. The
# picker sends the viewport's own center with every executePlugin call,
# so a plugin registered with requires_parent=False has a real,
# host-decided place to spawn - the gap that made requires_parent=False
# unrepresentable in v1 and, with it, forced every plugin (System Prompt
# included) to demand a pre-existing selected node before it could run.
# None only when a host constructs a context outside the picker path;
# parentless creation falls back to the origin in that case.
spawn_x: float | None = None
spawn_y: float | None = None


@dataclass(frozen=True)
class NodeKindSpec:
plugin_id: str
kind: str # ALREADY namespaced: f"{plugin_id}.{local_kind}"
factory: NodeFactory
requires_parent: bool # always True in v1
# False = creatable with nothing selected, spawned at the picker's
# reported viewport center (PluginRunContext.spawn_x/spawn_y).
requires_parent: bool
# ADR-014 stage 14.2: OPTIONAL persistence/wire hooks - see
# HostContext.register_node_kind's own docstring for the full contract.
# A plugin that passes neither still gets its node's universal title/
Expand Down Expand Up @@ -449,6 +461,10 @@ class BuiltinActionSpec:
description: str
category: str
handler: BuiltinActionHandler
# Mirrors PluginNodeKindSpec.requires_parent: False lets the picker run
# this action with nothing selected. The handler still owns its own
# validation - the host only stops gating the call on a parent id.
requires_parent: bool = True


# SECURITY-FIX: register_builtin_plugin (below) attaches a picker action that
Expand Down Expand Up @@ -501,10 +517,14 @@ def register_node_kind(
contains ".", so a namespaced kind can never literally collide with
one, and no separate reserved-kind blocklist is needed.

v1 ONLY supports requires_parent=True. False is rejected outright:
PluginPicker.tsx's executePlugin(name, parentId) wire call carries
no x/y at all, so a parentless node has no host-decided place to
spawn.
`requires_parent=False` registers a kind that can be created on an
empty canvas with nothing selected. It was rejected outright in v1
because executePlugin's wire call carried no x/y, leaving a
parentless node with no host-decided place to spawn; the call now
carries the viewport center, surfaced to the factory as
PluginRunContext.spawn_x/spawn_y, so the position exists and the
restriction is gone. The factory is invoked with parent_node_id=None
in that case, and the created node is left unconnected.

ADR-014 stage 14.2: 'serialize'/'deserialize' are the OPTIONAL
generic persistence/wire seam for a plugin's own NodeState subclass
Expand Down Expand Up @@ -535,12 +555,6 @@ def register_node_kind(
here is caught by the caller - the node still restores with
its title/content, just without its extra state - never a
failed load."""
if not requires_parent:
raise PluginRegistrationError(
f'plugin "{self.plugin_id}": requires_parent=False is not supported in SDK '
f"v1 - the picker's executePlugin(name, parentId) call carries no spawn "
f'position for a parentless node.'
)
if not kind or not all(c.isalnum() or c == "_" for c in kind):
raise PluginRegistrationError(
f'plugin "{self.plugin_id}": invalid node kind "{kind}" '
Expand All @@ -552,7 +566,8 @@ def register_node_kind(
f'plugin "{self.plugin_id}": node kind "{kind}" already registered'
)
self._node_kinds[namespaced] = NodeKindSpec(
plugin_id=self.plugin_id, kind=namespaced, factory=factory, requires_parent=True,
plugin_id=self.plugin_id, kind=namespaced, factory=factory,
requires_parent=requires_parent,
serialize=serialize, deserialize=deserialize,
)

Expand Down Expand Up @@ -585,6 +600,7 @@ def register_picker_entry(

def register_builtin_plugin(
self, *, name: str, description: str, category: str, handler: BuiltinActionHandler,
requires_parent: bool = True,
) -> None:
"""ADR-014 stage 14.3: the first-party migration escape hatch - lets
a plugin's register() call attach a picker entry directly to an
Expand Down Expand Up @@ -646,7 +662,7 @@ async def would be needless ceremony this call path has no use for.
)
self._builtin_actions[name] = BuiltinActionSpec(
plugin_id=self.plugin_id, name=name, description=description,
category=category, handler=handler,
category=category, handler=handler, requires_parent=requires_parent,
)

def register_intent(
Expand Down
Loading
Loading