fix(compiler): thread actor-level system_prompt into graph node metadata #7

Merged
hurui200320 merged 1 commits from fix/compiler-thread-system-prompt into master 2026-05-22 07:08:20 +00:00
5 changed files with 569 additions and 4 deletions
+18 -1
View File
@@ -41,11 +41,28 @@ host application.
| Actor Node Type | LangGraph NodeType | Notes |
|---|---|---|
| `agent` | `AGENT` | LLM invocation node; resolved at runtime via `ProviderRegistryPort`. Graph-level `provider` and `model` are propagated as defaults into each AGENT node's metadata; per-node `config.provider`/`config.model` override them. |
| `agent` | `AGENT` | LLM invocation node; resolved at runtime via `ProviderRegistryPort`. Graph-level `provider`, `model`, and `system_prompt` act as per-node defaults (see below). |
| `tool` | `TOOL` | Tool execution node; reference verified at load time via `ToolRegistryPort` |
| `conditional` | `CONDITIONAL` | Routing node |
| `subgraph` | `SUBGRAPH` | Nested actor reference |
#### AGENT node defaults
Graph-level `provider`, `model`, and `system_prompt` are propagated as
fallback defaults into each AGENT node's `NodeConfig.metadata` via
`setdefault`. Per-node `config.provider` / `config.model` /
`config.system_prompt` override them. The `system_prompt` key is only
set in metadata when the actor defines a top-level `system_prompt`
value (including empty string); actors without the field do not carry
the key.
At runtime, `Node._execute_agent()` injects
`metadata["system_prompt"]` (when present) into the context dict
passed to `agent.process_message()`, so host agents can read it
without a direct reference to the raw `ActorConfigSchema`. Per-
execution `state.metadata["system_prompt"]` overrides the compiled
default.
LSP bindings are declared per-node in the `config.lsp_bindings` list:
```yaml
+70
View File
@@ -0,0 +1,70 @@
Feature: Compiler threads actor-level system_prompt into AGENT node metadata
The actor-level ``system_prompt`` field for graph actors must be
threaded into each AGENT node's ``NodeConfig.metadata`` by the
compiler, exactly as ``provider`` and ``model`` already are, via
``setdefault`` semantics so per-node overrides take precedence.
``Node._execute_agent()`` must also inject the node's
``metadata["system_prompt"]`` into the context dict that reaches
``agent.process_message()``, allowing host agents to read the prompt
without a direct reference to the raw ``ActorConfigSchema``.
Scenario: Actor-level system_prompt is threaded into each AGENT node metadata
Given a graph actor with system_prompt "You are a helpful assistant"
When the actor configuration is compiled
Then each AGENT node metadata contains system_prompt "You are a helpful assistant"
Scenario: Per-node system_prompt takes precedence over actor-level default
Given a graph actor with actor-level system_prompt "Actor default" and planner node system_prompt "Planner override"
When the actor configuration is compiled
Then the planner AGENT node metadata contains system_prompt "Planner override"
And the executor AGENT node metadata contains system_prompt "Actor default"
Scenario: process_message receives system_prompt from node metadata in context
Given an AGENT NodeConfig with system_prompt "You are an expert assistant" in metadata
And a provider registry stub that returns a capturing agent for "openai" and "gpt-4"
When the AGENT node is executed with empty state metadata
Then the capturing agent received system_prompt "You are an expert assistant" in context
Scenario: state metadata system_prompt overrides compiled node metadata at runtime
Given an AGENT NodeConfig with system_prompt "Compiled default" in metadata
And a provider registry stub that returns a capturing agent for "openai" and "gpt-4"
When the AGENT node is executed with state metadata containing system_prompt "Runtime override"
Then the capturing agent received system_prompt "Runtime override" in context
Scenario: state metadata system_prompt None overrides compiled default at runtime
Given an AGENT NodeConfig with system_prompt "Compiled default" in metadata
And a provider registry stub that returns a capturing agent for "openai" and "gpt-4"
When the AGENT node is executed with state metadata containing system_prompt None
Then the capturing agent received system_prompt None in context
Scenario: Empty-string system_prompt is threaded into AGENT node metadata
Given a graph actor with an empty system_prompt
When the actor configuration is compiled
Then each AGENT node metadata contains an empty system_prompt
Scenario: Empty-string system_prompt is passed into runtime context
Given an AGENT NodeConfig with an empty system_prompt in metadata
And a provider registry stub that returns a capturing agent for "openai" and "gpt-4"
When the AGENT node is executed with empty state metadata
Then the capturing agent received an empty system_prompt in context
Scenario: Non-AGENT nodes do not receive system_prompt in compiled metadata
Given a mixed graph actor with AGENT, TOOL, CONDITIONAL, and SUBGRAPH nodes, with system_prompt "ForAgentsOnly"
When the actor configuration is compiled
Then the agent node "worker" metadata contains system_prompt "ForAgentsOnly"
And the tool node "fetcher" metadata does not contain a system_prompt key
And the conditional node "check" metadata does not contain a system_prompt key
And the subgraph node "nested" metadata does not contain a system_prompt key
Scenario: Actor without system_prompt does not add system_prompt key to AGENT node metadata
Given a graph actor without a system_prompt
When the actor configuration is compiled
Then each AGENT node metadata does not contain a system_prompt key
Scenario: Absent system_prompt is excluded from runtime context
Given an AGENT NodeConfig without system_prompt in metadata
And a provider registry stub that returns a capturing agent for "openai" and "gpt-4"
When the AGENT node is executed with empty state metadata
Then the capturing agent context dict does not contain a system_prompt key
@@ -0,0 +1,459 @@
"""Behave step definitions for the compiler system_prompt threading feature."""
from __future__ import annotations
import asyncio
from typing import Any
from behave import given, then, when
from cleveractors.agents.base import Agent
from cleveractors.langgraph.nodes import Node, NodeConfig, NodeType
from cleveractors.langgraph.state import GraphState
# ---------------------------------------------------------------------------
# Shared actor-config helper
# ---------------------------------------------------------------------------
def _graph_actor_with_system_prompt(system_prompt: str | None) -> dict[str, Any]:
"""Return a minimal two-AGENT-node graph actor dict, with optional system_prompt."""
raw: dict[str, Any] = {
"name": "local/sys-prompt-test",
"type": "graph",
"provider": "openai",
"model": "gpt-4o-mini",
"description": "system_prompt threading test actor",
"route": {
"entry_node": "planner",
"exit_nodes": ["executor"],
"nodes": [
{
"id": "planner",
"name": "Planner",
"type": "agent",
"description": "planning node",
},
{
"id": "executor",
"name": "Executor",
"type": "agent",
"description": "execution node",
},
],
"edges": [
{"from_node": "planner", "to_node": "executor"},
],
},
}
if system_prompt is not None:
raw["system_prompt"] = system_prompt
return raw
# ---------------------------------------------------------------------------
# Capturing agent stub for context-inspection tests
# ---------------------------------------------------------------------------
class _CapturingAgent(Agent):
"""Agent stub that records the context dict passed to process_message."""
def __init__(self) -> None:
super().__init__(name="capturing-agent")
self.received_context: dict[str, Any] | None = None
async def process_message(
self, message: Any, context: dict[str, Any] | None = None
) -> Any:
self.received_context = dict(context) if context is not None else None
return "captured response"
def get_capabilities(self) -> list[str]:
return ["capture"]
class _CapturingProviderRegistry:
"""Registry stub that always returns the same _CapturingAgent instance."""
def __init__(self, provider: str, model: str) -> None:
self._provider = provider
self._model = model
self.agent: _CapturingAgent = _CapturingAgent()
def get(self, provider: str, model: str) -> Agent | None:
if provider == self._provider and model == self._model:
return self.agent
return None
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given('a graph actor with system_prompt "{system_prompt}"')
def given_graph_actor_with_system_prompt(context: Any, system_prompt: str) -> None:
context.actor_raw = _graph_actor_with_system_prompt(system_prompt)
@given(
'a graph actor with actor-level system_prompt "{actor_prompt}"'
' and planner node system_prompt "{node_prompt}"'
)
def given_graph_actor_with_planner_override(
context: Any, actor_prompt: str, node_prompt: str
) -> None:
raw = _graph_actor_with_system_prompt(actor_prompt)
# Inject per-node system_prompt into the planner node's config block.
for node in raw["route"]["nodes"]:
if node["id"] == "planner":
node["config"] = {"system_prompt": node_prompt}
break
context.actor_raw = raw
@given('an AGENT NodeConfig with system_prompt "{system_prompt}" in metadata')
def given_agent_nodeconfig_with_system_prompt(context: Any, system_prompt: str) -> None:
context.node_config = NodeConfig(
name="worker",
type=NodeType.AGENT,
agent=None,
metadata={
"provider": "openai",
"model": "gpt-4",
"system_prompt": system_prompt,
},
)
@given("a graph actor with an empty system_prompt")
def given_graph_actor_with_empty_system_prompt(context: Any) -> None:
context.actor_raw = _graph_actor_with_system_prompt("")
@given("an AGENT NodeConfig with an empty system_prompt in metadata")
def given_agent_nodeconfig_with_empty_system_prompt(context: Any) -> None:
context.node_config = NodeConfig(
name="worker",
type=NodeType.AGENT,
agent=None,
metadata={
"provider": "openai",
"model": "gpt-4",
"system_prompt": "",
},
)
@given("an AGENT NodeConfig without system_prompt in metadata")
def given_agent_nodeconfig_without_system_prompt(context: Any) -> None:
context.node_config = NodeConfig(
name="worker",
type=NodeType.AGENT,
agent=None,
metadata={
"provider": "openai",
"model": "gpt-4",
},
)
@given(
'a provider registry stub that returns a capturing agent for "{provider}" and "{model}"'
)
def given_capturing_provider_registry(context: Any, provider: str, model: str) -> None:
context.capturing_registry = _CapturingProviderRegistry(provider, model)
@given("a graph actor without a system_prompt")
def given_graph_actor_without_system_prompt(context: Any) -> None:
context.actor_raw = _graph_actor_with_system_prompt(None)
@given(
"a mixed graph actor with AGENT, TOOL, CONDITIONAL, and SUBGRAPH nodes,"
' with system_prompt "{system_prompt}"'
)
def given_mixed_graph_actor_all_node_types(context: Any, system_prompt: str) -> None:
raw: dict[str, Any] = {
"name": "local/mixed-test",
"type": "graph",
"provider": "openai",
"model": "gpt-4o-mini",
"description": "mixed node type test actor",
"system_prompt": system_prompt,
"route": {
"entry_node": "worker",
"exit_nodes": ["nested"],
"nodes": [
{
"id": "worker",
"name": "Worker",
"type": "agent",
"description": "agent node",
},
{
"id": "fetcher",
"name": "Fetcher",
"type": "tool",
"description": "tool node",
"config": {"tools": ["fetch_url"]},
},
{
"id": "check",
"name": "Checker",
"type": "conditional",
"description": "conditional routing node",
"config": {
"function": "check_result",
"condition": {"field": "status", "equals": "ok"},
},
},
{
"id": "nested",
"name": "Nested",
"type": "subgraph",
"description": "subgraph reference node",
"actor_ref": "local/nested-actor",
},
],
"edges": [
{"from_node": "worker", "to_node": "fetcher"},
{"from_node": "fetcher", "to_node": "check"},
{"from_node": "check", "to_node": "nested"},
],
},
}
context.actor_raw = raw
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when("the AGENT node is executed with empty state metadata")
def when_agent_node_executed_empty_metadata(context: Any) -> None:
node = Node(
config=context.node_config,
agents={},
provider_registry=context.capturing_registry,
)
state = GraphState(
messages=[{"role": "user", "content": "hello"}],
metadata={},
)
context.node_result = asyncio.run(node.execute(state))
@when(
'the AGENT node is executed with state metadata containing system_prompt "{prompt}"'
)
def when_agent_node_executed_with_state_metadata_system_prompt(
context: Any, prompt: str
) -> None:
node = Node(
config=context.node_config,
agents={},
provider_registry=context.capturing_registry,
)
state = GraphState(
messages=[{"role": "user", "content": "hello"}],
metadata={"system_prompt": prompt},
)
context.node_result = asyncio.run(node.execute(state))
@when("the AGENT node is executed with state metadata containing system_prompt None")
def when_agent_node_executed_with_state_metadata_system_prompt_none(
context: Any,
) -> None:
node = Node(
config=context.node_config,
agents={},
provider_registry=context.capturing_registry,
)
state = GraphState(
messages=[{"role": "user", "content": "hello"}],
metadata={"system_prompt": None},
)
context.node_result = asyncio.run(node.execute(state))
# ---------------------------------------------------------------------------
# Then steps — compilation assertions
# ---------------------------------------------------------------------------
@then("each AGENT node metadata contains an empty system_prompt")
def then_each_agent_node_has_empty_system_prompt(context: Any) -> None:
compiled = context.compiled
agent_nodes_found = 0
for node_id, node_cfg in compiled.nodes.items():
if node_cfg.type == NodeType.AGENT:
agent_nodes_found += 1
actual = node_cfg.metadata.get("system_prompt")
assert actual == "", (
f"Node '{node_id}': expected empty system_prompt, got {actual!r}"
)
assert agent_nodes_found > 0, "no AGENT nodes found in compiled graph"
@then('each AGENT node metadata contains system_prompt "{expected}"')
def then_each_agent_node_has_system_prompt(context: Any, expected: str) -> None:
compiled = context.compiled
agent_nodes_found = 0
for node_id, node_cfg in compiled.nodes.items():
if node_cfg.type == NodeType.AGENT:
agent_nodes_found += 1
actual = node_cfg.metadata.get("system_prompt")
assert actual == expected, (
f"Node '{node_id}': expected metadata system_prompt={expected!r}, "
f"got {actual!r}"
)
assert agent_nodes_found > 0, "no AGENT nodes found in compiled graph"
@then('the planner AGENT node metadata contains system_prompt "{expected}"')
def then_planner_node_has_system_prompt(context: Any, expected: str) -> None:
compiled = context.compiled
node_cfg = compiled.nodes["planner"]
actual = node_cfg.metadata.get("system_prompt")
assert actual == expected, (
f"planner node: expected metadata system_prompt={expected!r}, got {actual!r}"
)
@then('the executor AGENT node metadata contains system_prompt "{expected}"')
def then_executor_node_has_system_prompt(context: Any, expected: str) -> None:
compiled = context.compiled
node_cfg = compiled.nodes["executor"]
actual = node_cfg.metadata.get("system_prompt")
assert actual == expected, (
f"executor node: expected metadata system_prompt={expected!r}, got {actual!r}"
)
@then("each AGENT node metadata does not contain a system_prompt key")
def then_each_agent_node_metadata_lacks_system_prompt(context: Any) -> None:
compiled = context.compiled
agent_nodes_found = 0
for node_id, node_cfg in compiled.nodes.items():
if node_cfg.type == NodeType.AGENT:
agent_nodes_found += 1
assert "system_prompt" not in node_cfg.metadata, (
f"node '{node_id}': expected no 'system_prompt' key in metadata, "
f"but found {node_cfg.metadata.get('system_prompt')!r}"
)
assert agent_nodes_found > 0, "no AGENT nodes found in compiled graph"
@then('the agent node "{node_id}" metadata contains system_prompt "{expected}"')
def then_agent_node_metadata_has_system_prompt(
context: Any, node_id: str, expected: str
) -> None:
compiled = context.compiled
assert node_id in compiled.nodes, (
f"node '{node_id}' not found in compiled nodes: {list(compiled.nodes.keys())}"
)
node_cfg = compiled.nodes[node_id]
actual = node_cfg.metadata.get("system_prompt")
assert actual == expected, (
f"node '{node_id}': expected metadata system_prompt={expected!r}, "
f"got {actual!r}"
)
@then('the tool node "{node_id}" metadata does not contain a system_prompt key')
def then_tool_node_metadata_lacks_system_prompt(context: Any, node_id: str) -> None:
compiled = context.compiled
assert node_id in compiled.nodes, (
f"node '{node_id}' not found in compiled nodes: {list(compiled.nodes.keys())}"
)
node_cfg = compiled.nodes[node_id]
assert "system_prompt" not in node_cfg.metadata, (
f"node '{node_id}' unexpectedly contains system_prompt in metadata: "
f"{node_cfg.metadata.get('system_prompt')!r}"
)
@then('the conditional node "{node_id}" metadata does not contain a system_prompt key')
def then_conditional_node_metadata_lacks_system_prompt(
context: Any, node_id: str
) -> None:
compiled = context.compiled
assert node_id in compiled.nodes, (
f"node '{node_id}' not found in compiled nodes: {list(compiled.nodes.keys())}"
)
node_cfg = compiled.nodes[node_id]
assert "system_prompt" not in node_cfg.metadata, (
f"node '{node_id}' unexpectedly contains system_prompt in metadata: "
f"{node_cfg.metadata.get('system_prompt')!r}"
)
@then('the subgraph node "{node_id}" metadata does not contain a system_prompt key')
def then_subgraph_node_metadata_lacks_system_prompt(context: Any, node_id: str) -> None:
compiled = context.compiled
assert node_id in compiled.nodes, (
f"node '{node_id}' not found in compiled nodes: {list(compiled.nodes.keys())}"
)
node_cfg = compiled.nodes[node_id]
assert "system_prompt" not in node_cfg.metadata, (
f"node '{node_id}' unexpectedly contains system_prompt in metadata: "
f"{node_cfg.metadata.get('system_prompt')!r}"
)
# ---------------------------------------------------------------------------
# Then steps — runtime assertions
# ---------------------------------------------------------------------------
@then("the capturing agent received an empty system_prompt in context")
def then_capturing_agent_received_empty_system_prompt(context: Any) -> None:
agent = context.capturing_registry.agent
assert agent.received_context is not None, (
"capturing agent process_message was never called"
)
actual = agent.received_context.get("system_prompt")
assert actual == "", f"context system_prompt: expected '', got {actual!r}"
@then('the capturing agent received system_prompt "{expected}" in context')
def then_capturing_agent_received_system_prompt(context: Any, expected: str) -> None:
agent = context.capturing_registry.agent
assert agent.received_context is not None, (
"capturing agent process_message was never called"
)
actual = agent.received_context.get("system_prompt")
assert actual == expected, (
f"context system_prompt: expected {expected!r}, got {actual!r}"
)
@then("the capturing agent received system_prompt None in context")
def then_capturing_agent_received_system_prompt_none(context: Any) -> None:
agent = context.capturing_registry.agent
assert agent.received_context is not None, (
"capturing agent process_message was never called"
)
assert "system_prompt" in agent.received_context, (
"expected 'system_prompt' key in context, but it is missing"
)
actual = agent.received_context["system_prompt"]
assert actual is None, f"context system_prompt: expected None, got {actual!r}"
@then("the capturing agent context dict does not contain a system_prompt key")
def then_capturing_agent_context_lacks_system_prompt(context: Any) -> None:
agent = context.capturing_registry.agent
assert agent.received_context is not None, (
"capturing agent process_message was never called"
)
assert "system_prompt" not in agent.received_context, (
f"context dict unexpectedly contains system_prompt: "
f"{agent.received_context.get('system_prompt')!r}"
)
+11 -3
View File
@@ -141,6 +141,7 @@ def _map_node(
node: NodeDefinition,
actor_provider: str | None = None,
actor_model: str | None = None,
actor_system_prompt: str | None = None,
) -> lg_nodes.NodeConfig:
"""Map an actor schema node to a LangGraph ``NodeConfig``.
@@ -152,17 +153,23 @@ def _map_node(
actor_model: The actor-level model (e.g. ``"gpt-4"``).
Used as a fallback for AGENT nodes that do not declare their
own ``model`` in their per-node config block.
actor_system_prompt: The actor-level system prompt.
Used as a fallback for AGENT nodes that do not declare their
own ``system_prompt`` in their per-node config block.
"""
lg_type = _NODE_TYPE_MAP.get(node.type, lg_nodes.NodeType.FUNCTION)
config = node.config
# For AGENT nodes, merge the actor-level provider/model as defaults so
# that Node._execute_agent can resolve the agent via ProviderRegistryPort
# even when the node's own config block omits them.
# For AGENT nodes, merge the actor-level provider/model/system_prompt as
# defaults so that Node._execute_agent can resolve the agent via
# ProviderRegistryPort and expose the system prompt even when the node's
# own config block omits them.
merged_meta: dict[str, Any] = dict(config)
if node.type == NodeType.AGENT:
merged_meta.setdefault("provider", actor_provider)
merged_meta.setdefault("model", actor_model)
if actor_system_prompt is not None:
merged_meta.setdefault("system_prompt", actor_system_prompt)
return lg_nodes.NodeConfig(
name=node.id,
@@ -323,6 +330,7 @@ def compile_actor(
node_def,
actor_provider=config.provider,
actor_model=config.model,
actor_system_prompt=config.system_prompt,
)
if node_def.type == NodeType.TOOL:
+11
View File
@@ -237,6 +237,16 @@ class Node: # pylint: disable=too-many-instance-attributes
if history_truncated:
context["_history_truncated"] = True
context["_history_original_length"] = len(state.messages)
# Expose the node's compiled system_prompt as a context default so
# that host agents can read it via context.get("system_prompt")
# without requiring a direct reference to the raw ActorConfigSchema.
# Applied before state.metadata so per-execution overrides take
# precedence over the compile-time default.
node_system_prompt: str | None = self.config.metadata.get("system_prompt")
# Guard against non-string values (e.g. int, list) injected by
# manually constructed NodeConfig; only string/none pass through.
if isinstance(node_system_prompt, str):
context["system_prompt"] = node_system_prompt
if state.metadata:
context.update(state.metadata)
nested_context = context.get("context")
@@ -272,6 +282,7 @@ class Node: # pylint: disable=too-many-instance-attributes
"full_context",
"_history_truncated",
"_history_original_length",
"system_prompt",
}
snapshot_keys = set(context_snapshot.keys()) if context_snapshot else set()
current_keys = set(context.keys())