The project is small on purpose. Six Python modules hold the machinery, and two XML files hold everything that makes one network different from another. The Python does not know which domain it is running.
What is real, and what is not. The orchestration runs: the LangGraph state machine, the routing, the synthesis and the human gate all execute against gpt-4o. Every agent's answer is written into the config file in advance, in its <mock_data> block. Nothing is fetched: no vendor API is called, and no MCP server exists.
Every response element below is text somebody typed, not a call.
That is a data-protection decision before it is a convenience one. Wired to live systems, a network like this would read real rates, booking records and guest reviews, and forward whatever it found to a language model. With every answer written in advance, the only things that leave your machine are the question you type and text you can read in this repository. It also makes a run repeatable, which is what lets the test suite exercise the real graph with no key, no network and nothing spent.
| File | Lines | What it does |
|---|---|---|
agentic_network/config.py | 120 | Parses a config into dataclasses and picks a scenario. |
agentic_network/graph.py | 197 | Builds and compiles the state machine. The core. |
agentic_network/channels.py | 108 | Where output goes and where human answers come from. |
agentic_network/cli.py | 67 | Headless entry point. What the notebook and Colab use. |
agentic_network/gui.py | 204 | The tkinter desktop client. |
agentic_network/__init__.py | 22 | The public surface: three names. |
tests/test_network.py | 112 | Nineteen checks against a stub model. No key needed. |
config/config_travel.xml | 105 | The travel partner network. |
config/config_agency.xml | 114 | The consulting firm. |
| Total | 1049 |
What a config declares
Four things: the prompt the broker routes with, the prompt it synthesises and scores risk with, the scenarios that select which reply each agent returns, and the agents themselves.
config/config_travel.xmlone agent<agent id="revenue_strategist" name="Revenue Strategist"> <description>pricing, rates</description> <mock_data default="Rates are optimal."> <response scenario_id="1">{"your_rate": 250, "comp_rate": 200, "variance": "Drop rate by $50", "recommendation": "Drop Rate"}</response> <response scenario_id="2">{"occupancy": "dropping", "recommendation": "Offer 15% Discount"}</response> <response scenario_id="3">{"your_rate": 200, "market_demand": "surge", "recommendation": "Raise Rate to $350"}</response> <response scenario_id="4">{"opportunity": "Multi-room bookings", "recommendation": "Create Family Package"}</response> <response scenario_id="5">{"alert": "Price War", "comp_move": "-$30 drop", "recommendation": "Match competitor rate or add value"}</response> </mock_data> </agent>
The description is the only thing the broker sees when it decides who to
consult, so it is doing real work despite being three words. Each
response is the reply that agent gives for one scenario, and
default covers anything unmatched.
Scenarios are keyword lists. A question is tested against them in document order and the first hit wins, so the most specific scenario goes first.
config/config_travel.xmlall five scenarios<scenarios> <scenario id="1" default="true"> <keyword>november</keyword> <keyword>bookings</keyword> </scenario> <scenario id="2"> <keyword>construction</keyword> <keyword>noise</keyword> </scenario> <scenario id="3"> <keyword>concert</keyword> <keyword>swift</keyword> <keyword>demand</keyword> </scenario> <scenario id="4"> <keyword>families</keyword> <keyword>family</keyword> <keyword>crib</keyword> </scenario> <scenario id="5"> <keyword>price</keyword> <keyword>competitor</keyword> </scenario> </scenarios>
Two configurations
Here is the whole difference between an online travel agency and a management consulting firm, as far as this codebase is concerned.
| config_travel.xml | config_agency.xml | |
|---|---|---|
| Agents | 5 | 5 |
| Roster | Market Scout Revenue Strategist Content Specialist Guest Experience Facilities Manager |
Market Intelligence Analyst Financial Modeler Organizational Strategist Operations Specialist Risk & Compliance Officer |
| Scenarios | 5 | 5 |
| Canned replies | 25 | 25 |
| Lines of Python that differ | 0 | |
The routing prompt
The travel network gives the model almost nothing. The consulting network gives it a role, which changes how it reads an ambiguous question.
travelYou are a Router. Select agents based on the user request.
Available Agents:
{agent_list}
Return ONLY a JSON list, e.g. ["market_scout", "revenue_strategist"]agencyYou are an Engagement Manager at a top-tier management consulting firm. Select the appropriate specialized agents to consult based on the user's request.
Available Agents:
{agent_list}
Return ONLY a JSON list of agent IDs, e.g. ["market_analyst", "financial_modeler"]The risk rule
This is the one that matters most, because it decides when a person is interrupted. It is prose in a prompt, not a policy engine, and the model is asked to apply it to its own recommendation.
travelUser Request: {user_input}
Agent Data: {agent_data}
Task:
1. Answer the user's request using the data.
2. DETERMINE RISK:
- If the recommendation suggests changing PRICES or publishing CONTENT, output "RISK: YES".
- If the recommendation is just analysis, reporting, or checking status, output "RISK: NO".agencyUser Request: {user_input}
Agent Data: {agent_data}
Task:
1. Synthesize the agents' findings into a cohesive, executive-level consulting recommendation for the client.
2. DETERMINE RISK:
- If the recommendation suggests LAYOFFS/REDUNDANCIES, BUDGET REALLOCATIONS over $1M, or MAJOR RESTRUCTURING, output "RISK: YES".
- If the recommendation is pure market research, risk assessment, or process mapping, output "RISK: NO".Prices and published content in one; layoffs, restructuring and budget moves over $1M in the other. Same gate, same graph, same code path.
The whole thing
Both configs and the module that turns them into a graph, in full.
config/config_travel.xml, 105 lines
config/config_travel.xml<?xml version="1.0" encoding="UTF-8"?> <system_config> <prompts> <router_prompt> <![CDATA[You are a Router. Select agents based on the user request. Available Agents: {agent_list} Return ONLY a JSON list, e.g. ["market_scout", "revenue_strategist"]]]> </router_prompt> <synthesis_prompt> <![CDATA[User Request: {user_input} Agent Data: {agent_data} Task: 1. Answer the user's request using the data. 2. DETERMINE RISK: - If the recommendation suggests changing PRICES or publishing CONTENT, output "RISK: YES". - If the recommendation is just analysis, reporting, or checking status, output "RISK: NO".]]> </synthesis_prompt> </prompts> <scenarios> <scenario id="1" default="true"> <keyword>november</keyword> <keyword>bookings</keyword> </scenario> <scenario id="2"> <keyword>construction</keyword> <keyword>noise</keyword> </scenario> <scenario id="3"> <keyword>concert</keyword> <keyword>swift</keyword> <keyword>demand</keyword> </scenario> <scenario id="4"> <keyword>families</keyword> <keyword>family</keyword> <keyword>crib</keyword> </scenario> <scenario id="5"> <keyword>price</keyword> <keyword>competitor</keyword> </scenario> </scenarios> <agents> <agent id="market_scout" name="Market Scout"> <description>events, weather</description> <mock_data default="No significant market alerts."> <response scenario_id="1">{"event": "Tech Summit", "status": "CANCELLED", "impact": "negative demand"}</response> <response scenario_id="2">{"alert": "City Construction", "location": "Main St", "duration": "2 weeks"}</response> <response scenario_id="3">{"event": "Taylor Swift Eras Tour", "status": "NEW_ANNOUNCEMENT", "impact": "massive demand spike"}</response> <response scenario_id="4">{"demographic": "Families", "flight_capacity": "+20%", "origin": "Domestic"}</response> <response scenario_id="5">{"market_status": "stable", "competitor_activity": "aggressive discounting"}</response> </mock_data> </agent> <agent id="revenue_strategist" name="Revenue Strategist"> <description>pricing, rates</description> <mock_data default="Rates are optimal."> <response scenario_id="1">{"your_rate": 250, "comp_rate": 200, "variance": "Drop rate by $50", "recommendation": "Drop Rate"}</response> <response scenario_id="2">{"occupancy": "dropping", "recommendation": "Offer 15% Discount"}</response> <response scenario_id="3">{"your_rate": 200, "market_demand": "surge", "recommendation": "Raise Rate to $350"}</response> <response scenario_id="4">{"opportunity": "Multi-room bookings", "recommendation": "Create Family Package"}</response> <response scenario_id="5">{"alert": "Price War", "comp_move": "-$30 drop", "recommendation": "Match competitor rate or add value"}</response> </mock_data> </agent> <agent id="content_specialist" name="Content Specialist"> <description>photos, description</description> <mock_data default="Content score is 100%."> <response scenario_id="1">{"issue": "Seasonality mismatch", "current_img": "Pool", "suggested_img": "Fireplace"}</response> <response scenario_id="2">{"action": "Update Description: Construction Warning.", "text": "Entrance via side door due to roadwork"}</response> <response scenario_id="3">{"action": "SEO Update: Concert Keywords", "keyword": "Near Stadium", "status": "Applied"}</response> <response scenario_id="4">{"action": "Add amenity: Cribs", "detail": "Tag as an amenity so the listing appears in family filters"}</response> <response scenario_id="5">{"value_prop": "Free Breakfast", "action": "Highlight in Hero Image"}</response> </mock_data> </agent> <agent id="guest_experience" name="Guest Experience"> <description>reviews, sentiment</description> <mock_data default="Sentiment is stable."> <response scenario_id="1">{"sentiment": "Neutral", "recent_issues": "None"}</response> <response scenario_id="2">{"sentiment": "Negative", "keywords": ["Noise", "Drilling", "Early Morning"]}</response> <response scenario_id="3">{"sentiment": "Positive", "keywords": ["Excited", "Concert"]}</response> <response scenario_id="4">{"sentiment": "Positive", "keywords": ["Pool Safety", "Kids Club"]}</response> <response scenario_id="5">{"sentiment": "Mixed", "keywords": ["Expensive", "Not worth it"]}</response> </mock_data> </agent> <agent id="facilities_manager" name="Facilities Manager"> <description>maintenance, housekeeping, physical inventory</description> <mock_data default="All facilities operating normally."> <response scenario_id="1">{"status": "low_occupancy", "action_recommended": "Schedule deep cleaning and preventative maintenance for empty wings."}</response> <response scenario_id="2">{"alert": "External noise high", "action_recommended": "Place complimentary earplugs and apology notes in street-facing rooms."}</response> <response scenario_id="3">{"status": "max_occupancy", "action_recommended": "Approve overtime for housekeeping to ensure rapid room turnover before the event."}</response> <response scenario_id="4">{"inventory_check": "Cribs and rollaway beds", "in_use": 2, "available": 8, "status": "sufficient"}</response> <response scenario_id="5">{"status": "standard", "opportunity": "Approve free late checkout requests to increase perceived guest value."}</response> </mock_data> </agent> </agents> </system_config>
config/config_agency.xml, 114 lines
config/config_agency.xml<?xml version="1.0" encoding="UTF-8"?> <system_config> <prompts> <router_prompt> <![CDATA[You are an Engagement Manager at a top-tier management consulting firm. Select the appropriate specialized agents to consult based on the user's request. Available Agents: {agent_list} Return ONLY a JSON list of agent IDs, e.g. ["market_analyst", "financial_modeler"]]]> </router_prompt> <synthesis_prompt> <![CDATA[User Request: {user_input} Agent Data: {agent_data} Task: 1. Synthesize the agents' findings into a cohesive, executive-level consulting recommendation for the client. 2. DETERMINE RISK: - If the recommendation suggests LAYOFFS/REDUNDANCIES, BUDGET REALLOCATIONS over $1M, or MAJOR RESTRUCTURING, output "RISK: YES". - If the recommendation is pure market research, risk assessment, or process mapping, output "RISK: NO".]]> </synthesis_prompt> </prompts> <scenarios> <scenario id="1" default="true"> <keyword>supply</keyword> <keyword>chain</keyword> <keyword>freight</keyword> <keyword>delays</keyword> </scenario> <scenario id="2"> <keyword>competitor</keyword> <keyword>tech</keyword> <keyword>ai</keyword> <keyword>launch</keyword> </scenario> <scenario id="3"> <keyword>merger</keyword> <keyword>acquisition</keyword> <keyword>redundancies</keyword> <keyword>headcount</keyword> <keyword>synergies</keyword> </scenario> <scenario id="4"> <keyword>expansion</keyword> <keyword>market</keyword> <keyword>europe</keyword> <keyword>hub</keyword> </scenario> <scenario id="5"> <keyword>esg</keyword> <keyword>carbon</keyword> <keyword>compliance</keyword> <keyword>regulations</keyword> </scenario> </scenarios> <agents> <agent id="market_analyst" name="Market Intelligence Analyst"> <description>Competitor moves, industry trends, macroeconomics, market share.</description> <mock_data default="Market conditions are currently stable with no major disruptive trends detected."> <response scenario_id="1">{"trend": "Near-shoring", "insight": "Competitors are moving manufacturing to Mexico to avoid trans-Pacific shipping delays."}</response> <response scenario_id="2">{"competitor_action": "Product Launch", "threat_level": "High", "insight": "Competitor X just launched an AI-driven platform capturing 5% market share in Q1."}</response> <response scenario_id="3">{"trend": "Industry Consolidation", "insight": "MA activity is up 15% in this sector; market expects high synergy realization."}</response> <response scenario_id="4">{"market_growth": "Europe", "cagr": "8%", "insight": "EU market presents strong growth, but local competition is deeply entrenched."}</response> <response scenario_id="5">{"trend": "Green Consumerism", "insight": "60% of target demographic now factoring carbon footprint into purchasing decisions."}</response> </mock_data> </agent> <agent id="financial_modeler" name="Financial Modeler"> <description>Revenue projections, cost analysis, margin impact, ROI calculations.</description> <mock_data default="Current financial margins are aligned with industry benchmarks."> <response scenario_id="1">{"cost_impact": "Freight costs up 15%", "margin_pressure": "Severe", "recommendation": "Hedge fuel costs and renegotiate 3PL contracts."}</response> <response scenario_id="2">{"revenue_at_risk": "$15M annually", "roi_to_match": "18 months", "recommendation": "Allocate $5M RD budget to fast-track matching AI capabilities."}</response> <response scenario_id="3">{"synergy_target": "$50M", "opex_reduction": "12%", "recommendation": "Consolidate overlapping administrative and IT budgets immediately."}</response> <response scenario_id="4">{"capex_required": "$10M initial investment", "payback_period": "3.5 years", "recommendation": "Approve funding gate 1 for regional infrastructure."}</response> <response scenario_id="5">{"tax_exposure": "$5M annual carbon tax", "mitigation_cost": "$2M retrofitting", "recommendation": "Invest in energy efficiency to realize a net $3M annual saving."}</response> </mock_data> </agent> <agent id="org_strategist" name="Organizational Strategist"> <description>Workforce planning, change management, leadership, restructuring.</description> <mock_data default="Organizational health is stable; retention rates are normal."> <response scenario_id="1">{"workforce_gap": "Procurement Agility", "recommendation": "Upskill procurement team on alternative sourcing and dynamic contract negotiation."}</response> <response scenario_id="2">{"talent_gap": "AI Engineering", "recommendation": "Acqui-hire a boutique AI startup to rapidly onboard necessary technical talent."}</response> <response scenario_id="3">{"action": "Restructuring", "impact": "10% headcount reduction in overlapping departments.", "recommendation": "Draft severance packages and retention bonuses for key personnel."}</response> <response scenario_id="4">{"hiring_need": "Regional Leadership", "recommendation": "Initiate executive search for a European Managing Director with localized network."}</response> <response scenario_id="5">{"leadership_gap": "Sustainability", "recommendation": "Appoint a Chief Sustainability Officer (CSO) to report directly to the CEO."}</response> </mock_data> </agent> <agent id="operations_specialist" name="Operations Specialist"> <description>Supply chain efficiency, process optimization, technology/ERP adoption.</description> <mock_data default="Core operational processes are functioning at target utilization rates."> <response scenario_id="1">{"bottleneck": "Tier-1 Suppliers", "action": "Activate secondary suppliers in LATAM to bypass current port congestion."}</response> <response scenario_id="2">{"tech_debt": "Legacy Cloud Infrastructure", "action": "Migrate core databases to scalable cloud architecture to support new AI workloads."}</response> <response scenario_id="3">{"integration": "ERP Systems", "action": "Migrate acquired company onto the parent instance of SAP within 90 days."}</response> <response scenario_id="4">{"logistics": "Distribution", "action": "Establish a centralized fulfillment hub in Germany to serve the EU market."}</response> <response scenario_id="5">{"facility_update": "Manufacturing Plants", "action": "Retrofit top 3 producing plants with IoT energy monitoring sensors."}</response> </mock_data> </agent> <agent id="risk_compliance" name="Risk & Compliance Officer"> <description>Regulatory changes, legal risks, PR impact, data privacy.</description> <mock_data default="No imminent regulatory or compliance risks detected."> <response scenario_id="1">{"risk_level": "Low", "concern": "Quality control variations with new suppliers.", "mitigation": "Deploy on-site quality assurance auditors to LATAM facilities."}</response> <response scenario_id="2">{"risk_level": "Medium", "concern": "Data privacy and AI bias.", "mitigation": "Establish an AI ethics committee before pushing products to production."}</response> <response scenario_id="3">{"risk_level": "High", "concern": "Employment law violations and PR backlash from layoffs.", "mitigation": "Engage local labor counsel to ensure compliance with WARN Act/local equivalents."}</response> <response scenario_id="4">{"risk_level": "High", "concern": "GDPR compliance and local employment laws.", "mitigation": "Audit all data collection methods to ensure strict EU GDPR adherence."}</response> <response scenario_id="5">{"risk_level": "Critical", "concern": "Non-compliance fines up to $10M.", "mitigation": "Accelerate ESG reporting audits and certify current emissions baseline."}</response> </mock_data> </agent> </agents> </system_config>
agentic_network/graph.py, 197 lines
agentic_network/graph.py"""The LangGraph state machine: broker, specialists, and a human gate. Shape of a run: user question -> broker decides which specialists to consult -> specialists fan out in parallel, each returns its canned reply -> broker synthesises one answer and scores its risk -> human_approval only when the risk score says so -> END Every node is generated from the config, so the roster is data rather than code. """ from __future__ import annotations import json import os from dataclasses import dataclass from typing import Annotated, Any, TypedDict from langchain_core.messages import HumanMessage, SystemMessage from langgraph.graph import END, StateGraph from .channels import Channel, ConsoleChannel from .config import NetworkConfig def _merge(left: dict | None, right: dict | None) -> dict: """Reducer letting parallel agent nodes write into one dict without clobbering.""" return {**(left or {}), **(right or {})} class AgentState(TypedDict, total=False): input: str scenario_id: int broker_plan: list[str] agent_outputs: Annotated[dict[str, str], _merge] final_response: str requires_approval: bool approval_decision: str def _default_llm(): """A ChatOpenAI reading its key from the environment. Imported lazily so the rest of the package can be used, and tested, without langchain_openai installed or a key present. """ from langchain_openai import ChatOpenAI if not os.environ.get("OPENAI_API_KEY"): raise RuntimeError( "OPENAI_API_KEY is not set. Copy .env.example to .env and put your key " "there, or export it in your shell. See scripts/check_key.py." ) return ChatOpenAI(model="gpt-4o", temperature=0) @dataclass class Network: """A compiled network, ready to answer questions.""" config: NetworkConfig channel: Channel app: Any def run(self, question: str, scenario_id: int | None = None) -> str: """Answer one question. Returns the final text.""" if scenario_id is None: scenario_id = self.config.scenario_for(question) self.channel.log(f"scenario {scenario_id} selected for: {question!r}") state = self.app.invoke( {"input": question, "scenario_id": scenario_id, "agent_outputs": {}} ) return state.get("final_response", "") def build_network( config: NetworkConfig, llm=None, channel: Channel | None = None, ) -> Network: """Turn a config into a runnable network.""" llm = llm or _default_llm() channel = channel or ConsoleChannel() def specialist(agent_id: str): agent = config.agents[agent_id] def node(state: AgentState) -> dict: reply = agent.reply_for(state["scenario_id"]) channel.status(f"Consulting {agent.name} ...") channel.log(f"{agent.name} returned: {reply}") return {"agent_outputs": {agent.name: reply}} return node def broker(state: AgentState) -> dict: outputs = state.get("agent_outputs") or {} if outputs: channel.status("Synthesising and scoring risk ...") prompt = ( config.synthesis_prompt .replace("{user_input}", state["input"]) .replace("{agent_data}", json.dumps(outputs)) ) answer = llm.invoke([SystemMessage(content=prompt)]).content needs_approval = "RISK: YES" in answer.upper() channel.log(f"synthesis complete, requires_approval={needs_approval}") if not needs_approval: channel.chat(answer) channel.status("Ready") return {"final_response": answer, "requires_approval": needs_approval} channel.status("Analysing intent ...") system = config.router_prompt.replace("{agent_list}", config.roster()) raw = llm.invoke( [SystemMessage(content=system), HumanMessage(content=state["input"])] ).content plan = _parse_plan(raw, config, channel) channel.status(f"Delegating to {', '.join(plan)}") return {"broker_plan": plan} def human_approval(state: AgentState) -> dict: proposal = state["final_response"] channel.status("Waiting for human approval ...") channel.log(f"proposal put to a human:\n{proposal}") if channel.ask_approval(proposal): channel.log("human approved") # Deliver exactly the text that was approved. Routing back through the # broker would re-run synthesis and hand the user a different answer # from the one they authorised. final = proposal + "\n\n[STATUS: APPROVED BY HUMAN, EXECUTED]" channel.chat(final) channel.status("Ready") return {"approval_decision": "APPROVED", "final_response": final} channel.log("human rejected") final = "ACTION CANCELLED. The human operator rejected the proposal.\n\nWhat was proposed:\n" + proposal channel.chat(final) channel.status("Ready") return {"approval_decision": "REJECTED", "final_response": final} graph = StateGraph(AgentState) graph.add_node("broker", broker) graph.add_node("human_approval", human_approval) for agent_id in config.agents: graph.add_node(agent_id, specialist(agent_id)) def route(state: AgentState): if state.get("requires_approval"): return "human_approval" if state.get("final_response"): return END return state.get("broker_plan") or [next(iter(config.agents))] graph.set_entry_point("broker") graph.add_conditional_edges( "broker", route, list(config.agents) + ["human_approval", END] ) for agent_id in config.agents: graph.add_edge(agent_id, "broker") # The gate is terminal. Going back to the broker would re-synthesise. graph.add_edge("human_approval", END) return Network(config=config, channel=channel, app=graph.compile()) def _parse_plan(raw: str, config: NetworkConfig, channel: Channel) -> list[str]: """Read the router's JSON list of agent ids, and say so when it is malformed. Falls back to a single agent rather than raising, but logs every reason it did: unparseable output, a non-list, or names that are not in the roster. An unlogged fallback is indistinguishable from a real routing decision. """ cleaned = raw.replace("```json", "").replace("```", "").strip() fallback = [next(iter(config.agents))] try: plan = json.loads(cleaned) except json.JSONDecodeError as exc: channel.log(f"router returned unparseable JSON ({exc}); falling back. Raw: {raw!r}") return fallback if not isinstance(plan, list): channel.log(f"router returned {type(plan).__name__}, expected a list; falling back.") return fallback known = [a for a in plan if a in config.agents] if unknown := [a for a in plan if a not in config.agents]: channel.log(f"router named agents that do not exist, ignoring: {unknown}") if not known: channel.log("router named no valid agents; falling back.") return fallback return known
agentic_network/config.py, 120 lines
agentic_network/config.py"""Load an agent network definition from XML. One XML file describes a whole network: the prompt the broker uses to route, the prompt it uses to synthesise, the demo scenarios, and for each agent its identity, its advertised capability and its canned reply per scenario. """ from __future__ import annotations import re import xml.etree.ElementTree as ET from dataclasses import dataclass, field from pathlib import Path @dataclass class Agent: """One specialist in the network.""" id: str name: str description: str default_reply: str replies: dict[int, str] = field(default_factory=dict) def reply_for(self, scenario_id: int) -> str: """The canned reply this agent gives for a scenario. Every reply is written into the config in advance. No agent calls a real API, so no live business data passes through this network. """ return self.replies.get(scenario_id, self.default_reply) @dataclass class Scenario: """A demo situation, selected by keyword match against the user's question.""" id: int keywords: list[str] @dataclass class NetworkConfig: router_prompt: str synthesis_prompt: str scenarios: list[Scenario] default_scenario: int agents: dict[str, Agent] def scenario_for(self, prompt: str) -> int: """Pick a scenario by keyword. Keywords match on word boundaries with an optional plural, so "crib" matches "cribs" while "ai" matches "AI" and "AI-driven" but not "retail", "explain" or "email". Substring matching would catch all three and route them to the wrong scenario without raising anything. First match in document order wins, so order the scenarios from most to least specific. """ text = prompt.lower() for sc in self.scenarios: for kw in sc.keywords: if re.search(rf"\b{re.escape(kw)}(?:s|es)?\b", text): return sc.id return self.default_scenario def roster(self) -> str: """The agent list as the router prompt wants to see it.""" return "\n".join(f"- {a.id} ({a.description})" for a in self.agents.values()) def _text(node, path: str) -> str: found = node.find(path) if found is None or found.text is None: raise ValueError(f"config is missing required element: {path}") return found.text.strip() def load_config(xml_path: str | Path) -> NetworkConfig: """Parse an agent network from XML.""" xml_path = Path(xml_path) if not xml_path.exists(): raise FileNotFoundError(f"No config at {xml_path}") root = ET.parse(xml_path).getroot() scenarios: list[Scenario] = [] default_scenario = 1 for sc in root.findall("./scenarios/scenario"): sc_id = int(sc.get("id")) if sc.get("default") == "true": default_scenario = sc_id scenarios.append( Scenario(sc_id, [k.text.strip().lower() for k in sc.findall("keyword") if k.text]) ) agents: dict[str, Agent] = {} for ag in root.findall("./agents/agent"): data = ag.find("mock_data") agents[ag.get("id")] = Agent( id=ag.get("id"), name=ag.get("name"), description=_text(ag, "description"), default_reply=data.get("default", ""), replies={ int(r.get("scenario_id")): (r.text or "").strip() for r in data.findall("response") }, ) if not agents: raise ValueError(f"{xml_path} defines no agents") return NetworkConfig( router_prompt=_text(root, "./prompts/router_prompt"), synthesis_prompt=_text(root, "./prompts/synthesis_prompt"), scenarios=scenarios, default_scenario=default_scenario, agents=agents, )
agentic_network/channels.py, 108 lines
agentic_network/channels.py"""Where the network's output goes, and where human answers come from. The graph itself never prints and never calls input(). It talks to a Channel. That is what lets the same graph drive a terminal session, a desktop window or a notebook cell without the nodes knowing which. """ from __future__ import annotations import queue import threading class Channel: """Interface between the running graph and whoever is watching it.""" def status(self, message: str) -> None: """One-line progress, meant to be overwritten by the next one.""" def log(self, message: str) -> None: """Detail for the trace log.""" def chat(self, message: str) -> None: """A message addressed to the user.""" def ask_approval(self, proposal: str) -> bool: """Block until a human approves or rejects. Return True to proceed.""" raise NotImplementedError class SilentChannel(Channel): """Discards everything and auto-rejects. Useful in tests.""" def ask_approval(self, proposal: str) -> bool: return False class ConsoleChannel(Channel): """Terminal and notebook output. `verbose` turns on the per-agent trace. `auto_approve` answers the human-in-the-loop gate without prompting, which is what lets a notebook run top to bottom unattended; leave it None to actually ask. """ def __init__(self, verbose: bool = False, auto_approve: bool | None = None): self.verbose = verbose self.auto_approve = auto_approve def status(self, message: str) -> None: if self.verbose: print(f"[status] {message}") def log(self, message: str) -> None: if self.verbose: print(f"[trace] {message}") def chat(self, message: str) -> None: print(message) def ask_approval(self, proposal: str) -> bool: print("\n" + "=" * 62) print("HUMAN APPROVAL REQUIRED") print("=" * 62) print(proposal) print("=" * 62) if self.auto_approve is not None: verdict = "approved" if self.auto_approve else "rejected" print(f"(auto_approve={self.auto_approve}: {verdict} without prompting)") return self.auto_approve return input(">> Authorize this change? (yes/no): ").strip().lower().startswith("y") class QueueChannel(Channel): """Feeds a GUI running the graph on a worker thread. Messages go onto a queue the UI polls. `ask_approval` blocks the worker on an Event until the UI calls `submit_approval` from the main thread. """ def __init__(self): self.queue: queue.Queue[dict] = queue.Queue() self._answered = threading.Event() self._answer = False def _put(self, kind: str, message: str) -> None: self.queue.put({"type": kind, "msg": message}) def status(self, message: str) -> None: self._put("status", message) def log(self, message: str) -> None: self._put("log", message) def chat(self, message: str) -> None: self._put("chat", message) def ask_approval(self, proposal: str) -> bool: self._put("chat", f"**APPROVAL REQUIRED**\n\n{proposal}\n\n**Authorize? (yes/no)**") self._put("system", "requires_input") self._answered.clear() self._answered.wait() return self._answer def submit_approval(self, text: str) -> None: """Called from the UI thread with the human's answer.""" self._answer = text.strip().lower().startswith("y") self._answered.set()
To run any of it, see Running it.