diff --git a/docs/reference/actor_compiler.md b/docs/reference/actor_compiler.md index f717126..6471f14 100644 --- a/docs/reference/actor_compiler.md +++ b/docs/reference/actor_compiler.md @@ -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 diff --git a/features/compiler_system_prompt.feature b/features/compiler_system_prompt.feature new file mode 100644 index 0000000..a8aa029 --- /dev/null +++ b/features/compiler_system_prompt.feature @@ -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 diff --git a/features/steps/compiler_system_prompt_steps.py b/features/steps/compiler_system_prompt_steps.py new file mode 100644 index 0000000..5c1eadd --- /dev/null +++ b/features/steps/compiler_system_prompt_steps.py @@ -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}" + ) diff --git a/src/cleveractors/actor/compiler.py b/src/cleveractors/actor/compiler.py index 3218e97..1d189da 100644 --- a/src/cleveractors/actor/compiler.py +++ b/src/cleveractors/actor/compiler.py @@ -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: diff --git a/src/cleveractors/langgraph/nodes.py b/src/cleveractors/langgraph/nodes.py index 74e3223..e755169 100644 --- a/src/cleveractors/langgraph/nodes.py +++ b/src/cleveractors/langgraph/nodes.py @@ -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())