diff --git a/examples/templates/viral_tech_copywriter/README.md b/examples/templates/viral_tech_copywriter/README.md new file mode 100644 index 0000000000..8af4db15c1 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/README.md @@ -0,0 +1,45 @@ +# Viral Tech Copywriter + +## What it does + +- **Job:** Turns a **marketing brief** (collected in chat) into **structured brief JSON**, + then **hooks plus per-channel copy**, then **exports** the bundle as **HTML** and/or **Markdown** + (`save_data` / `append_data` / `serve_file_to_user` only—no extra tools package features). +- **Flow:** `intake` (HITL) → `normalize-brief` → `write-package` → `deliver-exports` (terminal). + Delivery uses **hive-tools** MCP: `save_data`, `append_data`, `serve_file_to_user`, + `load_data`, `list_data_files`, `edit_data`. +- **Honesty:** Prompts forbid inventing metrics, customers, or quotes. Uncertain claims belong + in `verify_flags` / notes. + +## Run + +`mcp_servers.json` **must** be present next to this package (it ships with the template). +**`run`** and **`tui`** call `ViralTechCopywriterAgent.load_hive_tools_registry()` at startup and +**exit with a clear error** if the file is missing, instead of failing later when delivery tools +are invoked. The config starts the hive-tools MCP server from the repo `tools/` directory +(`uv run python mcp_server.py --stdio`). + +```bash +PYTHONPATH=core:examples/templates uv run python -m viral_tech_copywriter validate +PYTHONPATH=core:examples/templates uv run python -m viral_tech_copywriter tui +``` + +Configure the LLM via `~/.hive/configuration.json` like other Hive agents. + +## Outputs + +- **`raw_brief`:** Plain text from intake. +- **`structured_brief`:** JSON string (product, ICP, value props, platforms, etc.). +- **`copy_package`:** JSON string (`hooks[]`, `channels{}`, optional `notes`). +- **`delivered_artifacts`:** JSON string listing chosen formats and served files (URIs/paths). + +**HTML** is served with `open_in_browser=true` when possible. **Markdown** (e.g. +`viral_copywriter_report.md`) is served as a **clickable file link** (`open_in_browser=false`) +so the user can open it in an editor or preview. + +## Tests + +```bash +PYTHONPATH=core:examples/templates uv run python -m pytest \ + examples/templates/viral_tech_copywriter/tests/ -v +``` diff --git a/examples/templates/viral_tech_copywriter/__init__.py b/examples/templates/viral_tech_copywriter/__init__.py new file mode 100644 index 0000000000..ea171008a7 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/__init__.py @@ -0,0 +1,40 @@ +""" +Viral Tech Copywriter — conversational brief to multi-channel tech marketing copy. + +Flow: intake → normalize-brief → write-package → deliver-exports (HTML/Markdown via hive-tools MCP). +""" + +from __future__ import annotations + +from .agent import ( + ViralTechCopywriterAgent, + default_agent, + edges, + entry_node, + entry_points, + goal, + nodes, + pause_nodes, + skip_credential_validation, + terminal_nodes, +) +from .config import AgentMetadata, RuntimeConfig, default_config, metadata + +__version__ = "1.0.0" + +__all__ = [ + "ViralTechCopywriterAgent", + "default_agent", + "goal", + "nodes", + "edges", + "entry_node", + "entry_points", + "pause_nodes", + "terminal_nodes", + "skip_credential_validation", + "RuntimeConfig", + "AgentMetadata", + "default_config", + "metadata", +] diff --git a/examples/templates/viral_tech_copywriter/__main__.py b/examples/templates/viral_tech_copywriter/__main__.py new file mode 100644 index 0000000000..e0897e4e54 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/__main__.py @@ -0,0 +1,163 @@ +""" +CLI for Viral Tech Copywriter — TUI, validate, info, and non-TUI run entry points. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys + +import click + +from .agent import ViralTechCopywriterAgent, default_agent + + +def setup_logging(verbose: bool = False, debug: bool = False) -> None: + if debug: + level, fmt = logging.DEBUG, "%(asctime)s %(name)s: %(message)s" + elif verbose: + level, fmt = logging.INFO, "%(message)s" + else: + level, fmt = logging.WARNING, "%(levelname)s: %(message)s" + logging.basicConfig(level=level, format=fmt, stream=sys.stderr) + logging.getLogger("framework").setLevel(level) + + +@click.group() +@click.version_option(version="1.0.0") +def cli() -> None: + """Viral Tech Copywriter — brief to hooks and channel copy.""" + pass + + +@cli.command("run") +@click.option("--quiet", "-q", is_flag=True, help="Only output result JSON") +@click.option("--verbose", "-v", is_flag=True, help="Show execution details") +@click.option("--debug", is_flag=True, help="Show debug logging") +def run_cmd(quiet: bool, verbose: bool, debug: bool) -> None: + """Execute one graph run (uses intake; best suited for TUI or scripted harness).""" + if not quiet: + setup_logging(verbose=verbose, debug=debug) + + try: + result = asyncio.run(default_agent.run({})) + except FileNotFoundError as exc: + click.echo(str(exc), err=True) + sys.exit(1) + + output_data = { + "success": result.success, + "steps_executed": result.steps_executed, + "output": result.output, + } + if result.error: + output_data["error"] = result.error + + click.echo(json.dumps(output_data, indent=2, default=str)) + sys.exit(0 if result.success else 1) + + +@cli.command() +@click.option("--verbose", "-v", is_flag=True, help="Show execution details") +@click.option("--debug", is_flag=True, help="Show debug logging") +def tui(verbose: bool, debug: bool) -> None: + """Launch the TUI for interactive copywriting.""" + setup_logging(verbose=verbose, debug=debug) + + try: + from framework.tui.app import AdenTUI + except ImportError: + click.echo("TUI requires the 'textual' package. Install with: uv pip install textual") + sys.exit(1) + + from pathlib import Path + + from framework.llm import LiteLLMProvider + from framework.runtime.agent_runtime import create_agent_runtime + from framework.runtime.execution_stream import EntryPointSpec + + async def run_with_tui() -> None: + agent = ViralTechCopywriterAgent() + registry = ViralTechCopywriterAgent.load_hive_tools_registry() + + storage_path = Path.home() / ".hive" / "agents" / "viral_tech_copywriter" + storage_path.mkdir(parents=True, exist_ok=True) + + llm = LiteLLMProvider( + model=agent.config.model, + api_key=agent.config.api_key, + api_base=agent.config.api_base, + ) + + tools = list(registry.get_tools().values()) + tool_executor = registry.get_executor() + graph = agent._build_graph() + + runtime = create_agent_runtime( + graph=graph, + goal=agent.goal, + storage_path=storage_path, + entry_points=[ + EntryPointSpec( + id="start", + name="Start copywriter", + entry_node="intake", + trigger_type="manual", + isolation_level="isolated", + ), + ], + llm=llm, + tools=tools, + tool_executor=tool_executor, + ) + + await runtime.start() + + try: + app = AdenTUI(runtime) + await app.run_async() + finally: + await runtime.stop() + + try: + asyncio.run(run_with_tui()) + except FileNotFoundError as exc: + click.echo(str(exc), err=True) + sys.exit(1) + + +@cli.command() +@click.option("--json", "output_json", is_flag=True) +def info(output_json: bool) -> None: + """Show agent metadata and graph summary.""" + info_data = default_agent.info() + if output_json: + click.echo(json.dumps(info_data, indent=2)) + else: + click.echo(f"Agent: {info_data['name']}") + click.echo(f"Description: {info_data['description']}") + click.echo(f"\nNodes: {', '.join(info_data['nodes'])}") + click.echo(f"Client-facing: {', '.join(info_data['client_facing_nodes'])}") + click.echo(f"Entry: {info_data['entry_node']}") + click.echo(f"Terminal: {', '.join(info_data['terminal_nodes'])}") + + +@cli.command() +def validate() -> None: + """Validate graph structure.""" + validation = default_agent.validate() + if validation["valid"]: + click.echo("Agent is valid") + for warning in validation["warnings"]: + click.echo(f" WARNING: {warning}") + else: + click.echo("Agent has errors:") + for error in validation["errors"]: + click.echo(f" ERROR: {error}") + sys.exit(0 if validation["valid"] else 1) + + +if __name__ == "__main__": + cli() diff --git a/examples/templates/viral_tech_copywriter/agent.py b/examples/templates/viral_tech_copywriter/agent.py new file mode 100644 index 0000000000..1a978edc79 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/agent.py @@ -0,0 +1,323 @@ +"""Agent graph construction for Viral Tech Copywriter (Option B: interactive intake).""" + +from __future__ import annotations + +from pathlib import Path + +from framework.graph import Constraint, EdgeCondition, EdgeSpec, Goal, SuccessCriterion +from framework.graph.edge import GraphSpec +from framework.graph.executor import ExecutionResult, GraphExecutor +from framework.llm import LiteLLMProvider +from framework.runner.tool_registry import ToolRegistry +from framework.runtime.core import Runtime +from framework.runtime.event_bus import EventBus + +from .config import default_config, metadata +from .nodes import ( + deliver_exports_node, + intake_node, + normalize_brief_node, + write_package_node, +) + +_PACKAGE_DIR = Path(__file__).resolve().parent +MCP_CONFIG_FILENAME = "mcp_servers.json" + +# Loads hive-tools MCP (save_data, append_data, serve_file_to_user, etc.). +skip_credential_validation = True + +goal = Goal( + id="viral-tech-copywriter-goal", + name="Viral Tech Copywriter", + description=( + "Capture a marketing brief through conversation, normalize it into structured " + "JSON, produce hook-heavy platform copy, then export HTML and/or Markdown with " + "user-chosen formats—without fabricating facts." + ), + success_criteria=[ + SuccessCriterion( + id="sc-brief-captured", + description="Intake produces a raw_brief with product, ICP, and platforms", + metric="brief_completeness", + target="true", + weight=0.15, + ), + SuccessCriterion( + id="sc-structured", + description="structured_brief matches schema and uses only provided facts", + metric="brief_schema", + target="valid", + weight=0.18, + ), + SuccessCriterion( + id="sc-hooks", + description="Multiple distinct hook angles, not trivial paraphrases", + metric="hook_diversity", + target=">=4", + weight=0.15, + ), + SuccessCriterion( + id="sc-channels", + description="Requested platforms receive tailored copy with CTAs where apt", + metric="channel_coverage", + target="100%", + weight=0.15, + ), + SuccessCriterion( + id="sc-constraints", + description="Honors tone, banned phrases, and no fabricated metrics", + metric="constraint_adherence", + target="true", + weight=0.12, + ), + SuccessCriterion( + id="sc-export", + description=( + "User-selected HTML/Markdown files created, served, and listed in " + "delivered_artifacts" + ), + metric="export_delivery", + target="true", + weight=0.25, + ), + ], + constraints=[ + Constraint( + id="c-no-fabrication", + description="Do not invent customers, metrics, logos, or press quotes", + constraint_type="hard", + category="quality", + ), + Constraint( + id="c-verify", + description="Flag uncertain claims in verify_flags or notes, not as facts", + constraint_type="hard", + category="quality", + ), + Constraint( + id="c-json-outputs", + description=( + "normalize-brief and write-package emit parseable JSON; " + "delivered_artifacts lists served files" + ), + constraint_type="hard", + category="functional", + ), + ], +) + +nodes = [ + intake_node, + normalize_brief_node, + write_package_node, + deliver_exports_node, +] + +edges = [ + EdgeSpec( + id="intake-to-normalize", + source="intake", + target="normalize-brief", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), + EdgeSpec( + id="normalize-to-write", + source="normalize-brief", + target="write-package", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), + EdgeSpec( + id="write-to-deliver", + source="write-package", + target="deliver-exports", + condition=EdgeCondition.ON_SUCCESS, + priority=1, + ), +] + +entry_node = "intake" +entry_points = {"start": "intake"} +pause_nodes = [] +terminal_nodes = ["deliver-exports"] + + +class ViralTechCopywriterAgent: + """Viral Tech Copywriter — intake → normalize-brief → write-package → deliver-exports.""" + + @classmethod + def mcp_config_path(cls) -> Path: + """Absolute path to ``mcp_servers.json`` next to this package (hive-tools stdio config).""" + return _PACKAGE_DIR / MCP_CONFIG_FILENAME + + @classmethod + def load_hive_tools_registry(cls) -> ToolRegistry: + """ + Build a ``ToolRegistry`` with hive-tools MCP loaded from ``mcp_servers.json``. + + Raises: + FileNotFoundError: If the config file is missing (required for ``deliver-exports``). + """ + path = cls.mcp_config_path() + if not path.is_file(): + msg = ( + f"Required MCP config not found: {path}\n" + "The Viral Tech Copywriter template ships with mcp_servers.json; restore it or " + "run from the package directory. Without it, hive-tools (save_data, " + "serve_file_to_user, etc.) cannot load. See README.md." + ) + raise FileNotFoundError(msg) + registry = ToolRegistry() + registry.load_mcp_config(path) + return registry + + def __init__(self, config=None): + self.config = config or default_config + self.goal = goal + self.nodes = nodes + self.edges = edges + self.entry_node = entry_node + self.entry_points = entry_points + self.pause_nodes = pause_nodes + self.terminal_nodes = terminal_nodes + self._executor: GraphExecutor | None = None + self._graph: GraphSpec | None = None + self._event_bus: EventBus | None = None + self._tool_registry: ToolRegistry | None = None + + def _build_graph(self) -> GraphSpec: + return GraphSpec( + id="viral-tech-copywriter-graph", + goal_id=self.goal.id, + version="1.0.0", + entry_node=self.entry_node, + entry_points=self.entry_points, + terminal_nodes=self.terminal_nodes, + pause_nodes=self.pause_nodes, + nodes=self.nodes, + edges=self.edges, + default_model=self.config.model, + max_tokens=self.config.max_tokens, + loop_config={ + "max_iterations": 55, + "max_tool_calls_per_turn": 36, + "max_history_tokens": 32000, + }, + ) + + def _setup(self) -> GraphExecutor: + storage_path = Path.home() / ".hive" / "agents" / "viral_tech_copywriter" + storage_path.mkdir(parents=True, exist_ok=True) + + self._event_bus = EventBus() + self._tool_registry = self.load_hive_tools_registry() + + llm = LiteLLMProvider( + model=self.config.model, + api_key=self.config.api_key, + api_base=self.config.api_base, + ) + + tool_executor = self._tool_registry.get_executor() + tools = list(self._tool_registry.get_tools().values()) + + self._graph = self._build_graph() + runtime = Runtime(storage_path) + + self._executor = GraphExecutor( + runtime=runtime, + llm=llm, + tools=tools, + tool_executor=tool_executor, + event_bus=self._event_bus, + storage_path=storage_path, + loop_config=self._graph.loop_config, + ) + + return self._executor + + async def start(self) -> None: + if self._executor is None: + self._setup() + + async def stop(self) -> None: + self._executor = None + self._event_bus = None + self._tool_registry = None + + async def trigger_and_wait( + self, + entry_point: str, + input_data: dict, + timeout: float | None = None, + session_state: dict | None = None, + ) -> ExecutionResult | None: + if self._executor is None: + raise RuntimeError("Agent not started. Call start() first.") + if self._graph is None: + raise RuntimeError("Graph not built. Call start() first.") + + return await self._executor.execute( + graph=self._graph, + goal=self.goal, + input_data=input_data, + session_state=session_state, + ) + + async def run(self, context: dict, session_state=None) -> ExecutionResult: + await self.start() + try: + result = await self.trigger_and_wait("start", context, session_state=session_state) + return result or ExecutionResult(success=False, error="Execution timeout") + finally: + await self.stop() + + def info(self): + return { + "name": metadata.name, + "version": metadata.version, + "description": metadata.description, + "goal": { + "name": self.goal.name, + "description": self.goal.description, + }, + "nodes": [n.id for n in self.nodes], + "edges": [e.id for e in self.edges], + "entry_node": self.entry_node, + "entry_points": self.entry_points, + "pause_nodes": self.pause_nodes, + "terminal_nodes": self.terminal_nodes, + "client_facing_nodes": [n.id for n in self.nodes if n.client_facing], + } + + def validate(self): + errors = [] + warnings = [] + + node_ids = {node.id for node in self.nodes} + for edge in self.edges: + if edge.source not in node_ids: + errors.append(f"Edge {edge.id}: source '{edge.source}' not found") + if edge.target not in node_ids: + errors.append(f"Edge {edge.id}: target '{edge.target}' not found") + + if self.entry_node not in node_ids: + errors.append(f"Entry node '{self.entry_node}' not found") + + for terminal in self.terminal_nodes: + if terminal not in node_ids: + errors.append(f"Terminal node '{terminal}' not found") + + for ep_id, node_id in self.entry_points.items(): + if node_id not in node_ids: + errors.append(f"Entry point '{ep_id}' references unknown node '{node_id}'") + + return { + "valid": len(errors) == 0, + "errors": errors, + "warnings": warnings, + } + + +default_agent = ViralTechCopywriterAgent() diff --git a/examples/templates/viral_tech_copywriter/config.py b/examples/templates/viral_tech_copywriter/config.py new file mode 100644 index 0000000000..ff8fcd4b30 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/config.py @@ -0,0 +1,27 @@ +"""Runtime configuration for Viral Tech Copywriter.""" + +from dataclasses import dataclass + +from framework.config import RuntimeConfig + +default_config = RuntimeConfig() + + +@dataclass +class AgentMetadata: + name: str = "Viral Tech Copywriter" + version: str = "1.0.0" + description: str = ( + "Turns a conversational marketing brief into structured brief JSON, " + "hook-heavy channel copy, and optional HTML/Markdown exports—without posting " + "or external research tools." + ) + intro_message: str = ( + "Hi! I'm your viral tech copywriter. Tell me what you're shipping (product, " + "audience, proof, tone, and which channels you need). I'll ask one quick " + "clarifying question if needed, then generate hooks, channel-ready copy, " + "and your choice of HTML and/or Markdown export." + ) + + +metadata = AgentMetadata() diff --git a/examples/templates/viral_tech_copywriter/flowchart.json b/examples/templates/viral_tech_copywriter/flowchart.json new file mode 100644 index 0000000000..6d4e9f8df1 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/flowchart.json @@ -0,0 +1,182 @@ +{ + "original_draft": { + "agent_name": "viral_tech_copywriter", + "goal": "Capture a marketing brief through conversation, normalize it into structured JSON, produce hook-heavy platform copy, then export HTML and/or Markdown with user-chosen formats—without fabricating facts.", + "description": "", + "success_criteria": [ + "Intake produces a raw_brief with product, ICP, and platforms", + "structured_brief matches schema and uses only provided facts", + "Multiple distinct hook angles, not trivial paraphrases", + "Requested platforms receive tailored copy with CTAs where apt", + "Honors tone, banned phrases, and no fabricated metrics", + "User-selected HTML/Markdown files created, served, and listed in delivered_artifacts" + ], + "constraints": [ + "Do not invent customers, metrics, logos, or press quotes", + "Flag uncertain claims in verify_flags or notes, not as facts", + "normalize-brief and write-package emit parseable JSON; delivered_artifacts lists served files" + ], + "nodes": [ + { + "id": "intake", + "name": "Intake", + "description": "Greets the user, gathers or clarifies the marketing brief, then outputs raw_brief for normalization.", + "node_type": "event_loop", + "tools": [], + "input_keys": [], + "output_keys": [ + "raw_brief" + ], + "success_criteria": "The raw_brief captures product, audience, differentiation, optional proof, tone, banned phrases, and target platforms from the conversation.", + "sub_agents": [], + "flowchart_type": "start", + "flowchart_shape": "stadium", + "flowchart_color": "#8aad3f" + }, + { + "id": "normalize-brief", + "name": "Normalize brief", + "description": "Normalizes raw_brief into fixed-schema structured_brief JSON.", + "node_type": "event_loop", + "tools": [], + "input_keys": [ + "raw_brief" + ], + "output_keys": [ + "structured_brief" + ], + "success_criteria": "structured_brief is valid JSON matching the required schema.", + "sub_agents": [], + "flowchart_type": "process", + "flowchart_shape": "rectangle", + "flowchart_color": "#b5a575" + }, + { + "id": "write-package", + "name": "Write copy package", + "description": "Produces hooks and per-channel copy from structured_brief.", + "node_type": "event_loop", + "tools": [], + "input_keys": [ + "structured_brief" + ], + "output_keys": [ + "copy_package" + ], + "success_criteria": "copy_package is valid JSON with distinct hooks and content for requested platforms only.", + "sub_agents": [], + "flowchart_type": "process", + "flowchart_shape": "rectangle", + "flowchart_color": "#b5a575" + }, + { + "id": "deliver-exports", + "name": "Deliver exports", + "description": "Offers HTML / Markdown / both via save_data and append_data, serves files to the user, and records delivered_artifacts.", + "node_type": "event_loop", + "tools": [ + "save_data", + "append_data", + "serve_file_to_user", + "load_data", + "list_data_files", + "edit_data" + ], + "input_keys": [ + "structured_brief", + "copy_package" + ], + "output_keys": [ + "delivered_artifacts" + ], + "success_criteria": "User chose format(s); HTML and/or Markdown files created and served; delivered_artifacts JSON lists files and URIs.", + "sub_agents": [], + "flowchart_type": "terminal", + "flowchart_shape": "stadium", + "flowchart_color": "#b5453a" + } + ], + "edges": [ + { + "id": "edge-0", + "source": "intake", + "target": "normalize-brief", + "condition": "on_success", + "description": "", + "label": "" + }, + { + "id": "edge-1", + "source": "normalize-brief", + "target": "write-package", + "condition": "on_success", + "description": "", + "label": "" + }, + { + "id": "edge-2", + "source": "write-package", + "target": "deliver-exports", + "condition": "on_success", + "description": "", + "label": "" + } + ], + "entry_node": "intake", + "terminal_nodes": [ + "deliver-exports" + ], + "flowchart_legend": { + "start": { + "shape": "stadium", + "color": "#8aad3f" + }, + "terminal": { + "shape": "stadium", + "color": "#b5453a" + }, + "process": { + "shape": "rectangle", + "color": "#b5a575" + }, + "decision": { + "shape": "diamond", + "color": "#d89d26" + }, + "io": { + "shape": "parallelogram", + "color": "#d06818" + }, + "document": { + "shape": "document", + "color": "#c4b830" + }, + "database": { + "shape": "cylinder", + "color": "#508878" + }, + "subprocess": { + "shape": "subroutine", + "color": "#887a48" + }, + "browser": { + "shape": "hexagon", + "color": "#cc8850" + } + } + }, + "flowchart_map": { + "intake": [ + "intake" + ], + "normalize-brief": [ + "normalize-brief" + ], + "write-package": [ + "write-package" + ], + "deliver-exports": [ + "deliver-exports" + ] + } +} diff --git a/examples/templates/viral_tech_copywriter/mcp_servers.json b/examples/templates/viral_tech_copywriter/mcp_servers.json new file mode 100644 index 0000000000..ebf5286fd8 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/mcp_servers.json @@ -0,0 +1,9 @@ +{ + "hive-tools": { + "transport": "stdio", + "command": "uv", + "args": ["run", "python", "mcp_server.py", "--stdio"], + "cwd": "../../../tools", + "description": "Hive tools MCP server (save_data, append_data, serve_file_to_user, etc.)" + } +} diff --git a/examples/templates/viral_tech_copywriter/nodes/__init__.py b/examples/templates/viral_tech_copywriter/nodes/__init__.py new file mode 100644 index 0000000000..a6376f9f15 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/nodes/__init__.py @@ -0,0 +1,253 @@ +"""Node definitions for Viral Tech Copywriter (Option B: intake → normalize → write).""" + +from __future__ import annotations + +from framework.graph import NodeSpec + +_INTAKE_PROMPT = """\ +You are the intake assistant for a Viral Tech Copywriter agent. + +**Your job:** Collect a solid creative brief from the user, then call set_output. + +**STEP 1 — First message (text only, no tool calls except ask_user):** +- Greet briefly and explain you need: what the product is, who it is for, why it \ +matters, any proof (quotes, metrics, logos—only what they provide), tone, words to \ +avoid, and which channels they want (e.g. X/Twitter thread, LinkedIn, landing hero, \ +email). +- If the user ALREADY pasted a detailed brief in their first message, acknowledge it. \ +Ask at most ONE clarifying question if something critical is missing (e.g. target \ +channels or tone). Otherwise skip extra questions. +- Call ask_user() and wait for their reply. + +**STEP 2 — After the user responds:** +- Combine everything into one clear brief (plain text or light markdown). +- Call: set_output("raw_brief", "") + +Do NOT invent customers, revenue, or study results. If they gave no metrics, do not \ +add any. +""" + +_NORMALIZE_PROMPT = """\ +You are a brief normalizer for a Viral Tech Copywriter agent. + +You receive **raw_brief** (free text from the intake conversation). Produce a single \ +JSON object (as a string) suitable for downstream copy generation. + +**Rules:** +- Only use facts present in raw_brief. Do NOT invent metrics, customers, or awards. +- For missing optional fields, use empty arrays or null as specified below. +- If something is assumed, add a short note under "assumptions" (not in customer voice). + +**Output:** Call set_output("structured_brief", "") with exactly this shape: +{ + "product_one_liner": "string", + "icp": "string", + "value_props": ["string", ...], + "proof_points": ["string", ...], + "tone": "string", + "banned_phrases": ["string", ...], + "platforms": ["twitter" | "linkedin" | "landing_hero" | "email" | "ad_primary", ...], + "assumptions": ["string", ...], + "verify_flags": ["string", ...] +} + +verify_flags: claims that need human verification before publishing (e.g. \ +"[VERIFY] revenue number"). Use [] if none. + +Escape the JSON properly for set_output (valid JSON in the string value). +""" + +_WRITE_PROMPT = """\ +You are a viral tech marketing copywriter. + +You receive **structured_brief** (JSON string). Parse it internally. Generate \ +scroll-stopping, specific copy—no generic AI fluff, no banned_phrases, no fabricated \ +facts. + +**Output:** Call set_output("copy_package", "") with this shape: +{ + "hooks": [ + {"angle": "pain|proof|curiosity|contrarian|story", "text": "hook line"}, + ... + ], + "channels": { + "twitter": {"body": "...", "thread_outline": ["tweet1", "tweet2", ...] or []}, + "linkedin": {"body": "..."}, + "landing_hero": {"headline": "...", "subhead": "...", "cta": "..."}, + "email": {"subject": "...", "preview": "...", "body": "..."}, + "ad_primary": {"body": "..."} + }, + "notes": ["optional implementation notes for the human"] +} + +**Rules:** +- Include at least 4 hooks with **distinct angles** (not the same line reworded). +- Only include channel keys that appear in structured_brief.platforms. Omit others or \ +use empty strings only for unused keys if you must keep shape—prefer omitting keys. +- Respect tone and banned_phrases. Use proof_points only as given; mark uncertain \ +claims in notes, do not state them as facts. +- Keep channel bodies within reasonable length for the platform (short posts for \ +Twitter, longer for LinkedIn). +""" + +_DATA_DELIVERY_TOOLS = [ + "save_data", + "append_data", + "serve_file_to_user", + "load_data", + "list_data_files", + "edit_data", +] + +_DELIVER_EXPORTS_PROMPT = """\ +You are the export assistant for a Viral Tech Copywriter agent. + +**Inputs (already in memory):** +- **structured_brief** — JSON string (normalized brief). +- **copy_package** — JSON string (hooks and per-channel copy). + +**STEP 1 — Parse and greet (text only, then ask_user):** +- Parse both JSON values internally. If either is invalid JSON, apologize briefly \ +and ask_user to rerun from intake; do not call file tools until valid. +- Tell the user the copy package is ready and ask which export they want: + - **html** — styled report; opens in the **browser** when served (best for review) + - **markdown** — **viral_copywriter_report.md**; plain `#` / `##` sections, easy to \ +edit in any editor or paste into Notion/GitHub + - **both** — HTML + Markdown files +Accept synonyms: "md", "markdown", "browser", "web page", "both", "all". +- Call **ask_user()** and wait. + +**STEP 2 — Decide formats from the user's reply:** +- Map their answer to one or more of: html, markdown. +- If unclear, ask one short follow-up via ask_user, then proceed. + +**STEP 3 — HTML export (if html or both):** +Use filename **viral_copywriter_report.html** unless a variant is needed \ +(e.g. viral_copywriter_report_v2.html). + +**CRITICAL:** Build HTML with **save_data** for the head + opening body + H1 + TOC \ +only, then **append_data** for each major section. Do NOT write the entire HTML in \ +one save_data call. + +**CSS (include in head):** +``` +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;\ +max-width:900px;margin:0 auto;padding:40px;line-height:1.6;color:#333} +h1{color:#1a1a1a;border-bottom:3px solid #2563eb;padding-bottom:12px} +h2{color:#2563eb;margin-top:28px} +.section{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;\ +padding:16px;margin:16px 0} +pre{white-space:pre-wrap;background:#1e293b;color:#e2e8f0;padding:12px;\ +border-radius:6px;font-size:0.9em} +ul{padding-left:20px} +``` + +**Suggested sections (append each with append_data):** +1. "Structured brief" — key fields from structured_brief as HTML. +2. "Hooks" — each hook angle + text from copy_package.hooks. +3. "Channel copy" — copy_package.channels as HTML subsections. + +Close with append_data: footer + ``. + +Then: +``` +serve_file_to_user(filename="viral_copywriter_report.html", \ +label="Viral copywriter report (HTML)", open_in_browser=true) +``` + +**STEP 4 — Markdown export (if markdown or both):** +Use **viral_copywriter_report.md** (or a variant if needed). + +**CRITICAL:** Use **save_data** for the first chunk only (`#` title, optional TOC \ +with markdown links), then **append_data** for each section (## Structured brief, \ +## Hooks, ## Channel copy). Same content as HTML but valid Markdown (lists, \ +headings, fenced code only if needed for raw copy). + +Then: +``` +serve_file_to_user(filename="viral_copywriter_report.md", \ +label="Viral copywriter report (Markdown)", open_in_browser=false) +``` +(clickable **file://** link; user opens in their editor or previewer.) + +**STEP 5 — Optional diagnostics:** +Use **list_data_files** or **load_data** if needed. **edit_data** only for small fixes. + +**STEP 6 — Finish:** +Call **set_output("delivered_artifacts", "")** with shape: +```json +{ + "formats_chosen": ["html", "markdown"], + "files": [ + {"filename": "...", "file_uri": "...", "label": "..."} + ] +} +``` +Use **serve_file_to_user** results. Omit entries for formats not chosen. +""" + +deliver_exports_node = NodeSpec( + id="deliver-exports", + name="Deliver exports", + description=( + "Offers HTML / Markdown / both via save_data and append_data, " + "serves files to the user, and records delivered_artifacts." + ), + node_type="event_loop", + client_facing=True, + input_keys=["structured_brief", "copy_package"], + output_keys=["delivered_artifacts"], + system_prompt=_DELIVER_EXPORTS_PROMPT, + tools=_DATA_DELIVERY_TOOLS, + success_criteria=( + "User chose format(s); HTML and/or Markdown files created and served; " + "delivered_artifacts JSON lists files and URIs." + ), +) + +intake_node = NodeSpec( + id="intake", + name="Intake", + description=( + "Greets the user, gathers or clarifies the marketing brief, " + "then outputs raw_brief for normalization." + ), + node_type="event_loop", + client_facing=True, + input_keys=[], + output_keys=["raw_brief"], + system_prompt=_INTAKE_PROMPT, + tools=[], + success_criteria=( + "The raw_brief captures product, audience, differentiation, optional proof, " + "tone, banned phrases, and target platforms from the conversation." + ), +) + +normalize_brief_node = NodeSpec( + id="normalize-brief", + name="Normalize brief", + description="Normalizes raw_brief into fixed-schema structured_brief JSON.", + node_type="event_loop", + client_facing=False, + input_keys=["raw_brief"], + output_keys=["structured_brief"], + system_prompt=_NORMALIZE_PROMPT, + tools=[], + success_criteria="structured_brief is valid JSON matching the required schema.", +) + +write_package_node = NodeSpec( + id="write-package", + name="Write copy package", + description="Produces hooks and per-channel copy from structured_brief.", + node_type="event_loop", + client_facing=False, + input_keys=["structured_brief"], + output_keys=["copy_package"], + system_prompt=_WRITE_PROMPT, + tools=[], + success_criteria=( + "copy_package is valid JSON with distinct hooks and content for requested platforms only." + ), +) diff --git a/examples/templates/viral_tech_copywriter/tests/conftest.py b/examples/templates/viral_tech_copywriter/tests/conftest.py new file mode 100644 index 0000000000..d0d655f2be --- /dev/null +++ b/examples/templates/viral_tech_copywriter/tests/conftest.py @@ -0,0 +1,30 @@ +"""Path setup for Viral Tech Copywriter template tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_repo_root = Path(__file__).resolve().parents[4] +for _p in ("examples/templates", "core"): + _path = str(_repo_root / _p) + if _path not in sys.path: + sys.path.insert(0, _path) + +AGENT_PATH = str(Path(__file__).resolve().parents[1]) + + +@pytest.fixture(scope="session") +def agent_module(): + import importlib + + return importlib.import_module(Path(AGENT_PATH).name) + + +@pytest.fixture(scope="session") +def runner_loaded(): + from framework.runner.runner import AgentRunner + + return AgentRunner.load(AGENT_PATH, skip_credential_validation=True) diff --git a/examples/templates/viral_tech_copywriter/tests/test_structure.py b/examples/templates/viral_tech_copywriter/tests/test_structure.py new file mode 100644 index 0000000000..093b278838 --- /dev/null +++ b/examples/templates/viral_tech_copywriter/tests/test_structure.py @@ -0,0 +1,131 @@ +"""Structural tests for Viral Tech Copywriter.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from viral_tech_copywriter import ( + ViralTechCopywriterAgent, + default_agent, + edges, + entry_node, + entry_points, + goal, + nodes, + pause_nodes, + terminal_nodes, +) + +_EXPECTED_DELIVER_TOOLS = { + "save_data", + "append_data", + "serve_file_to_user", + "load_data", + "list_data_files", + "edit_data", +} + + +class TestGoalDefinition: + def test_goal_exists(self) -> None: + assert goal is not None + assert goal.id == "viral-tech-copywriter-goal" + assert len(goal.success_criteria) == 6 + assert len(goal.constraints) == 3 + + def test_success_criteria_weights_sum_to_one(self) -> None: + total = sum(sc.weight for sc in goal.success_criteria) + assert abs(total - 1.0) < 0.01 + + +class TestNodeStructure: + def test_four_nodes(self) -> None: + assert len(nodes) == 4 + assert nodes[0].id == "intake" + assert nodes[1].id == "normalize-brief" + assert nodes[2].id == "write-package" + assert nodes[3].id == "deliver-exports" + + def test_client_facing_intake_and_deliver(self) -> None: + assert nodes[0].client_facing is True + assert nodes[3].client_facing is True + + def test_normalize_and_write_not_client_facing(self) -> None: + assert nodes[1].client_facing is False + assert nodes[2].client_facing is False + + def test_tools_only_on_deliver(self) -> None: + assert nodes[0].tools == [] + assert nodes[1].tools == [] + assert nodes[2].tools == [] + assert set(nodes[3].tools) == _EXPECTED_DELIVER_TOOLS + + +class TestEdgeStructure: + def test_three_edges(self) -> None: + assert len(edges) == 3 + + def test_linear_path(self) -> None: + assert edges[0].source == "intake" + assert edges[0].target == "normalize-brief" + assert edges[1].source == "normalize-brief" + assert edges[1].target == "write-package" + assert edges[2].source == "write-package" + assert edges[2].target == "deliver-exports" + + +class TestGraphConfiguration: + def test_entry_node(self) -> None: + assert entry_node == "intake" + + def test_entry_points(self) -> None: + assert entry_points == {"start": "intake"} + + def test_pause_nodes(self) -> None: + assert pause_nodes == [] + + def test_terminal_nodes(self) -> None: + assert terminal_nodes == ["deliver-exports"] + + +class TestAgentClass: + def test_default_agent_created(self) -> None: + assert default_agent is not None + + def test_validate_passes(self) -> None: + result = default_agent.validate() + assert result["valid"] is True + assert len(result["errors"]) == 0 + + def test_agent_info(self) -> None: + info = default_agent.info() + assert info["name"] == "Viral Tech Copywriter" + assert info["entry_node"] == "intake" + assert set(info["client_facing_nodes"]) == {"intake", "deliver-exports"} + + +class TestRunnerLoad: + def test_agent_runner_load_succeeds(self, runner_loaded) -> None: + assert runner_loaded is not None + + +class TestMcpConfig: + def test_mcp_config_path_is_package_file(self) -> None: + path = ViralTechCopywriterAgent.mcp_config_path() + assert path.name == "mcp_servers.json" + assert path.parent == Path(__file__).resolve().parents[1] + assert path.is_file() + + def test_load_hive_tools_registry_missing_raises( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + missing = tmp_path / "missing_mcp_servers.json" + + @classmethod + def _fake_mcp_path(cls: type[ViralTechCopywriterAgent]) -> Path: + return missing + + monkeypatch.setattr(ViralTechCopywriterAgent, "mcp_config_path", _fake_mcp_path) + with pytest.raises(FileNotFoundError, match="Required MCP config"): + ViralTechCopywriterAgent.load_hive_tools_registry()