create-leafmesh 2.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- create_leafmesh/__init__.py +3 -0
- create_leafmesh/cli.py +252 -0
- create_leafmesh/create.py +106 -0
- create_leafmesh/templates/Dockerfile +21 -0
- create_leafmesh/templates/README.md +309 -0
- create_leafmesh/templates/agency/__init__.py +0 -0
- create_leafmesh/templates/agency/advisor_agent.py +151 -0
- create_leafmesh/templates/agency/external_agents.py +278 -0
- create_leafmesh/templates/agency/fallback_researcher_agent.py +80 -0
- create_leafmesh/templates/agency/greeter_agent.py +79 -0
- create_leafmesh/templates/agency/processor_agent.py +90 -0
- create_leafmesh/templates/agency/researcher_agent.py +99 -0
- create_leafmesh/templates/agency/scheduler_agent.py +67 -0
- create_leafmesh/templates/agency/tools.py +123 -0
- create_leafmesh/templates/claude_skills/leafmesh/SKILL.md +2049 -0
- create_leafmesh/templates/claude_skills/leafmesh/agent-config-fields.md +1309 -0
- create_leafmesh/templates/claude_skills/leafmesh/examples.md +537 -0
- create_leafmesh/templates/claude_skills/leafmesh/reference.md +492 -0
- create_leafmesh/templates/configs/config.yaml +1028 -0
- create_leafmesh/templates/docker-compose.yml +28 -0
- create_leafmesh/templates/dockerignore +17 -0
- create_leafmesh/templates/env +109 -0
- create_leafmesh/templates/gitignore +33 -0
- create_leafmesh/templates/hitl_stub_receiver.py +149 -0
- create_leafmesh/templates/main.py +105 -0
- create_leafmesh/templates/requirements.txt +10 -0
- create_leafmesh-2.1.0.dist-info/METADATA +6 -0
- create_leafmesh-2.1.0.dist-info/RECORD +31 -0
- create_leafmesh-2.1.0.dist-info/WHEEL +5 -0
- create_leafmesh-2.1.0.dist-info/entry_points.txt +2 -0
- create_leafmesh-2.1.0.dist-info/top_level.txt +1 -0
create_leafmesh/cli.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""CLI entry point for create-leafmesh."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from create_leafmesh import __version__
|
|
9
|
+
from create_leafmesh.create import create_project
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# ANSI helpers (graceful fallback on terminals that don't support color)
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
_CYAN = "\033[36m"
|
|
15
|
+
_GREEN = "\033[32m"
|
|
16
|
+
_WHITE = "\033[97m"
|
|
17
|
+
_DIM = "\033[2m"
|
|
18
|
+
_BOLD = "\033[1m"
|
|
19
|
+
_RESET = "\033[0m"
|
|
20
|
+
|
|
21
|
+
def _c(code: str, text: str) -> str:
|
|
22
|
+
"""Wrap text in an ANSI code, reset after."""
|
|
23
|
+
return f"{code}{text}{_RESET}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# ASCII art — block letters for LEAFMESH
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
BANNER = r"""
|
|
30
|
+
██╗ ███████╗ █████╗ ███████╗███╗ ███╗███████╗███████╗██╗ ██╗
|
|
31
|
+
██║ ██╔════╝██╔══██╗██╔════╝████╗ ████║██╔════╝██╔════╝██║ ██║
|
|
32
|
+
██║ █████╗ ███████║█████╗ ██╔████╔██║█████╗ ███████╗███████║
|
|
33
|
+
██║ ██╔══╝ ██╔══██║██╔══╝ ██║╚██╔╝██║██╔══╝ ╚════██║██╔══██║
|
|
34
|
+
███████╗███████╗██║ ██║██║ ██║ ╚═╝ ██║███████╗███████║██║ ██║
|
|
35
|
+
╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def print_banner(version: str) -> None:
|
|
39
|
+
print()
|
|
40
|
+
print(_c(_CYAN, BANNER), end="")
|
|
41
|
+
pad = " " * 2
|
|
42
|
+
print(f"{pad} create-leafmesh {_c(_DIM, 'v' + version)} "
|
|
43
|
+
f"{_c(_DIM, 'Agent mesh scaffolding tool')}")
|
|
44
|
+
print(f"{pad} " + "-" * 55)
|
|
45
|
+
print()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Step progress printer
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
def step(n: int, total: int, label: str, done: bool = False) -> None:
|
|
52
|
+
status = _c(_GREEN, "[ OK ]") if done else _c(_DIM, "[ ]")
|
|
53
|
+
print(f" {status} [{n}/{total}] {label}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def step_done(n: int, total: int, label: str) -> None:
|
|
57
|
+
print(f"\033[1A\r {_c(_GREEN, '[ OK ]')} [{n}/{total}] {label}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# File tree renderer
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
FILE_TREE = """\
|
|
64
|
+
{name}/
|
|
65
|
+
|-- agency/
|
|
66
|
+
| |-- greeter_agent.py
|
|
67
|
+
| |-- processor_agent.py
|
|
68
|
+
| |-- researcher_agent.py
|
|
69
|
+
| |-- advisor_agent.py
|
|
70
|
+
| |-- fallback_researcher_agent.py
|
|
71
|
+
| `-- scheduler_agent.py
|
|
72
|
+
|-- configs/
|
|
73
|
+
| `-- config.yaml
|
|
74
|
+
|-- main.py
|
|
75
|
+
|-- hitl_stub_receiver.py
|
|
76
|
+
|-- requirements.txt
|
|
77
|
+
|-- .env
|
|
78
|
+
|-- Dockerfile
|
|
79
|
+
`-- docker-compose.yml"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Next-steps box
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
def print_success_box(project_name: str, missing_keys: list[str]) -> None:
|
|
86
|
+
w = 58
|
|
87
|
+
|
|
88
|
+
def row(text: str = "") -> str:
|
|
89
|
+
return f" | {text:<{w - 4}}|"
|
|
90
|
+
|
|
91
|
+
def div() -> str:
|
|
92
|
+
return " +" + "-" * (w - 2) + "+"
|
|
93
|
+
|
|
94
|
+
def header(text: str) -> str:
|
|
95
|
+
return " | " + _c(_GREEN, _c(_BOLD, f"{text:<{w - 4}}")) + "|"
|
|
96
|
+
|
|
97
|
+
print()
|
|
98
|
+
print(div())
|
|
99
|
+
print(header(f"Project ready: {project_name}"))
|
|
100
|
+
print(div())
|
|
101
|
+
print(row())
|
|
102
|
+
print(row(f" cd {project_name}"))
|
|
103
|
+
print(row(f" pip install -r requirements.txt"))
|
|
104
|
+
if missing_keys:
|
|
105
|
+
print(row())
|
|
106
|
+
print(row(f" # Add to .env before starting:"))
|
|
107
|
+
for k in missing_keys:
|
|
108
|
+
print(row(f" # {k}=<your-key>"))
|
|
109
|
+
print(row())
|
|
110
|
+
print(row(f" Terminal 1: python hitl_stub_receiver.py"))
|
|
111
|
+
print(row(f" Terminal 2: python main.py"))
|
|
112
|
+
print(row())
|
|
113
|
+
print(row(f" Open: http://127.0.0.1:18820/docs"))
|
|
114
|
+
print(row())
|
|
115
|
+
print(div())
|
|
116
|
+
print(row(f" Docs: https://leafcraft.ai/docs"))
|
|
117
|
+
print(div())
|
|
118
|
+
print()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Validation / prompts
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
def validate_project_name(name: str) -> str:
|
|
125
|
+
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
|
|
126
|
+
raise argparse.ArgumentTypeError(
|
|
127
|
+
f"Invalid project name '{name}'. "
|
|
128
|
+
"Must start with a letter and contain only alphanumeric characters, "
|
|
129
|
+
"hyphens, or underscores."
|
|
130
|
+
)
|
|
131
|
+
return name
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def prompt_project_name() -> str:
|
|
135
|
+
while True:
|
|
136
|
+
name = input(" Project name: ").strip()
|
|
137
|
+
if not name:
|
|
138
|
+
print(" Name cannot be empty.")
|
|
139
|
+
continue
|
|
140
|
+
if not re.match(r"^[a-zA-Z][a-zA-Z0-9_-]*$", name):
|
|
141
|
+
print(" Must start with a letter, letters/numbers/hyphens/underscores only.")
|
|
142
|
+
continue
|
|
143
|
+
return name
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def prompt_env_vars() -> dict:
|
|
147
|
+
w = 55
|
|
148
|
+
print()
|
|
149
|
+
print(" " + "-" * w)
|
|
150
|
+
print(f" {'Environment setup':^{w}}")
|
|
151
|
+
print(f" {'(press Enter to skip any field)':^{w}}")
|
|
152
|
+
print(" " + "-" * w)
|
|
153
|
+
print()
|
|
154
|
+
|
|
155
|
+
env_vars: dict = {}
|
|
156
|
+
|
|
157
|
+
fields = [
|
|
158
|
+
("LEAFMESH_LICENSE_KEY", "License key (https://leafcraft.ai): "),
|
|
159
|
+
("LEAFMESH_ENV_TOKEN", "Env token (https://leafcraft.ai): "),
|
|
160
|
+
("OPENAI_API_KEY", "OpenAI key (https://platform.openai.com): "),
|
|
161
|
+
("ANTHROPIC_API_KEY", "Anthropic key (optional): "),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
for key, label in fields:
|
|
165
|
+
val = input(f" {label}").strip()
|
|
166
|
+
if val:
|
|
167
|
+
env_vars[key] = val
|
|
168
|
+
|
|
169
|
+
print()
|
|
170
|
+
return env_vars
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Main create flow
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
def _run_create(project_name: str | None, output_dir: str, no_git: bool) -> int:
|
|
177
|
+
print_banner(__version__)
|
|
178
|
+
|
|
179
|
+
if project_name is None:
|
|
180
|
+
project_name = prompt_project_name()
|
|
181
|
+
print()
|
|
182
|
+
else:
|
|
183
|
+
try:
|
|
184
|
+
project_name = validate_project_name(project_name)
|
|
185
|
+
except argparse.ArgumentTypeError as e:
|
|
186
|
+
print(f" Error: {e}")
|
|
187
|
+
return 1
|
|
188
|
+
|
|
189
|
+
env_vars = prompt_env_vars()
|
|
190
|
+
|
|
191
|
+
# Hand off to create_project, passing our step-printer so it can report progress
|
|
192
|
+
print(f" Creating {_c(_BOLD, project_name + '/')}")
|
|
193
|
+
print()
|
|
194
|
+
|
|
195
|
+
result = create_project(
|
|
196
|
+
project_name,
|
|
197
|
+
output_dir,
|
|
198
|
+
init_git=not no_git,
|
|
199
|
+
env_vars=env_vars,
|
|
200
|
+
step_fn=step,
|
|
201
|
+
step_done_fn=step_done,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
if result != 0:
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
# File tree — print line by line with dim so pipe chars aren't swallowed
|
|
208
|
+
print()
|
|
209
|
+
for line in FILE_TREE.format(name=project_name).splitlines():
|
|
210
|
+
print(_c(_DIM, line))
|
|
211
|
+
|
|
212
|
+
# Success box
|
|
213
|
+
missing = [k for k in ("LEAFMESH_LICENSE_KEY", "OPENAI_API_KEY") if k not in env_vars]
|
|
214
|
+
print_success_box(project_name, missing)
|
|
215
|
+
|
|
216
|
+
return 0
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# CLI entry point
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
def main(argv: list[str] | None = None) -> int:
|
|
223
|
+
parser = argparse.ArgumentParser(
|
|
224
|
+
prog="create-leafmesh",
|
|
225
|
+
description="Project scaffolding tool for LeafMesh",
|
|
226
|
+
epilog="""
|
|
227
|
+
examples:
|
|
228
|
+
create-leafmesh my-project Create a new project
|
|
229
|
+
create-leafmesh my-project -o /tmp Create in a specific directory
|
|
230
|
+
create-leafmesh Interactive mode
|
|
231
|
+
""",
|
|
232
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument("--version", action="version", version=f"create-leafmesh {__version__}")
|
|
235
|
+
parser.add_argument("project_name", nargs="?", default=None, help="Project name")
|
|
236
|
+
parser.add_argument("-o", "--output-dir", default=".", help="Output directory (default: current)")
|
|
237
|
+
parser.add_argument("--no-git", action="store_true", help="Skip git init")
|
|
238
|
+
|
|
239
|
+
args = parser.parse_args(argv)
|
|
240
|
+
try:
|
|
241
|
+
return _run_create(args.project_name, args.output_dir, args.no_git)
|
|
242
|
+
except KeyboardInterrupt:
|
|
243
|
+
print("\n\n Cancelled.\n")
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
try:
|
|
249
|
+
sys.exit(main())
|
|
250
|
+
except KeyboardInterrupt:
|
|
251
|
+
print("\n\n Cancelled.\n")
|
|
252
|
+
sys.exit(0)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Project scaffolding logic."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
10
|
+
|
|
11
|
+
RENAME_MAP = {
|
|
12
|
+
"env": ".env",
|
|
13
|
+
"gitignore": ".gitignore",
|
|
14
|
+
"dockerignore": ".dockerignore",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
SUBSTITUTE_EXTENSIONS = {".py", ".yaml", ".yml", ".txt", ".md", ".env", ""}
|
|
18
|
+
|
|
19
|
+
ENV_PLACEHOLDERS = {
|
|
20
|
+
"LEAFMESH_LICENSE_KEY": "your-license-key-here",
|
|
21
|
+
"LEAFMESH_ENV_TOKEN": "your-env-token-here",
|
|
22
|
+
"OPENAI_API_KEY": "your-key-here",
|
|
23
|
+
"ANTHROPIC_API_KEY": "your-anthropic-key",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Step labels — order matches execution
|
|
27
|
+
_STEPS = [
|
|
28
|
+
"Copying template files",
|
|
29
|
+
"Writing .env",
|
|
30
|
+
"Setting up git repository",
|
|
31
|
+
"Installing Claude Code skill",
|
|
32
|
+
]
|
|
33
|
+
_TOTAL = len(_STEPS)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _noop(*args, **kwargs) -> None:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def create_project(
|
|
41
|
+
project_name: str,
|
|
42
|
+
output_dir: str,
|
|
43
|
+
*,
|
|
44
|
+
init_git: bool = True,
|
|
45
|
+
env_vars: dict | None = None,
|
|
46
|
+
step_fn: Callable = _noop,
|
|
47
|
+
step_done_fn: Callable = _noop,
|
|
48
|
+
) -> int:
|
|
49
|
+
target = Path(output_dir).resolve() / project_name
|
|
50
|
+
|
|
51
|
+
if target.exists():
|
|
52
|
+
print(f"\n Error: '{target}' already exists.\n")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
# Step 1 — copy template
|
|
56
|
+
step_fn(1, _TOTAL, _STEPS[0])
|
|
57
|
+
shutil.copytree(TEMPLATES_DIR, target)
|
|
58
|
+
for path in target.rglob("*"):
|
|
59
|
+
if not path.is_file():
|
|
60
|
+
continue
|
|
61
|
+
if path.suffix not in SUBSTITUTE_EXTENSIONS:
|
|
62
|
+
continue
|
|
63
|
+
text = path.read_text(encoding="utf-8")
|
|
64
|
+
if "{{project_name}}" in text:
|
|
65
|
+
path.write_text(text.replace("{{project_name}}", project_name), encoding="utf-8")
|
|
66
|
+
step_done_fn(1, _TOTAL, _STEPS[0])
|
|
67
|
+
|
|
68
|
+
# Step 2 — .env
|
|
69
|
+
step_fn(2, _TOTAL, _STEPS[1])
|
|
70
|
+
if env_vars:
|
|
71
|
+
env_file = target / "env"
|
|
72
|
+
if env_file.exists():
|
|
73
|
+
text = env_file.read_text(encoding="utf-8")
|
|
74
|
+
for key, value in env_vars.items():
|
|
75
|
+
placeholder = ENV_PLACEHOLDERS.get(key)
|
|
76
|
+
if placeholder and placeholder in text:
|
|
77
|
+
text = text.replace(placeholder, value)
|
|
78
|
+
text = text.replace(f"# {key}={value}", f"{key}={value}")
|
|
79
|
+
elif f"# {key}=" in text:
|
|
80
|
+
text = re.sub(rf"# {re.escape(key)}=.*", f"{key}={value}", text)
|
|
81
|
+
env_file.write_text(text, encoding="utf-8")
|
|
82
|
+
for old_name, new_name in RENAME_MAP.items():
|
|
83
|
+
src = target / old_name
|
|
84
|
+
if src.exists():
|
|
85
|
+
src.rename(target / new_name)
|
|
86
|
+
step_done_fn(2, _TOTAL, _STEPS[1])
|
|
87
|
+
|
|
88
|
+
# Step 3 — git
|
|
89
|
+
step_fn(3, _TOTAL, _STEPS[2])
|
|
90
|
+
if init_git:
|
|
91
|
+
try:
|
|
92
|
+
subprocess.run(["git", "init", str(target)], capture_output=True, check=True)
|
|
93
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
94
|
+
pass
|
|
95
|
+
step_done_fn(3, _TOTAL, _STEPS[2])
|
|
96
|
+
|
|
97
|
+
# Step 4 — Claude skill
|
|
98
|
+
step_fn(4, _TOTAL, _STEPS[3])
|
|
99
|
+
claude_skills_src = target / "claude_skills"
|
|
100
|
+
if claude_skills_src.exists():
|
|
101
|
+
claude_dir = target / ".claude" / "skills"
|
|
102
|
+
claude_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
claude_skills_src.rename(claude_dir)
|
|
104
|
+
step_done_fn(4, _TOTAL, _STEPS[3])
|
|
105
|
+
|
|
106
|
+
return 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
FROM python:3.13-slim AS base
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
# System dependencies for compiled packages (grpcio, etc.)
|
|
6
|
+
RUN apt-get update && \
|
|
7
|
+
apt-get install -y --no-install-recommends gcc libffi-dev && \
|
|
8
|
+
rm -rf /var/lib/apt/lists/*
|
|
9
|
+
|
|
10
|
+
# Install Python dependencies
|
|
11
|
+
COPY requirements.txt .
|
|
12
|
+
RUN pip install --no-cache-dir -r requirements.txt
|
|
13
|
+
|
|
14
|
+
# Copy project files
|
|
15
|
+
COPY configs/ configs/
|
|
16
|
+
COPY agency/ agency/
|
|
17
|
+
COPY main.py .
|
|
18
|
+
|
|
19
|
+
EXPOSE 18820
|
|
20
|
+
|
|
21
|
+
CMD ["python", "main.py"]
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# {{project_name}}
|
|
2
|
+
|
|
3
|
+
A LeafMesh multi-agent project with Human-in-the-Loop (HITL), fan-in/fan-out patterns, smart memory, scheduled agents, and more.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 1. Install dependencies
|
|
9
|
+
python -m venv .venv && source .venv/bin/activate
|
|
10
|
+
pip install -r requirements.txt
|
|
11
|
+
|
|
12
|
+
# 2. Configure environment
|
|
13
|
+
# Edit .env with your API keys (OPENAI_API_KEY, LEAFMESH_LICENSE_KEY, Redis)
|
|
14
|
+
|
|
15
|
+
# 3. Start Redis (if not already running)
|
|
16
|
+
docker compose up redis -d
|
|
17
|
+
# Or: redis-server
|
|
18
|
+
|
|
19
|
+
# 4. Start the mesh
|
|
20
|
+
python main.py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The mesh starts an API server at **http://127.0.0.1:18820**. Visit `/docs` for interactive API docs.
|
|
24
|
+
|
|
25
|
+
## Project Structure
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
{{project_name}}/
|
|
29
|
+
configs/
|
|
30
|
+
config.yaml # Agent definitions, mesh wiring, HITL config
|
|
31
|
+
agency/
|
|
32
|
+
greeter_agent.py # LLM agent with @pre_compose
|
|
33
|
+
processor_agent.py # Programmatic agent with @conditional_chain
|
|
34
|
+
researcher_agent.py # LLM agent with @chain_with_results + smart memory
|
|
35
|
+
fallback_researcher_agent.py # Programmatic fast fallback (race pattern)
|
|
36
|
+
advisor_agent.py # LLM fan-in agent with @chain + @compose
|
|
37
|
+
scheduler_agent.py # Cron-scheduled agent (daily reports)
|
|
38
|
+
tools.py # Custom tools (@global_tool, @tool)
|
|
39
|
+
external_agents.py # Integration reference (CrewAI, LangGraph, etc.)
|
|
40
|
+
main.py # Entry point
|
|
41
|
+
hitl_stub_receiver.py # Webhook stub for testing HITL locally
|
|
42
|
+
requirements.txt
|
|
43
|
+
.env # API keys and config
|
|
44
|
+
Dockerfile
|
|
45
|
+
docker-compose.yml # Redis + app (one command)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Agent Flow
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
HITL Scenario 1 (system-initiated)
|
|
52
|
+
===================================
|
|
53
|
+
API request greeter_agent (LLM)
|
|
54
|
+
"greet_user" ─────────> |
|
|
55
|
+
v
|
|
56
|
+
client (human agent, HITL)
|
|
57
|
+
[outbound webhook → human reviews → inbound webhook]
|
|
58
|
+
|
|
|
59
|
+
v
|
|
60
|
+
processor_agent (programmatic, parallel)
|
|
61
|
+
┌──┼──┐
|
|
62
|
+
v v v
|
|
63
|
+
researcher fallback advisor (waits)
|
|
64
|
+
(LLM) (instant) |
|
|
65
|
+
└──────────────┘
|
|
66
|
+
advisor_agent (OR fan-in)
|
|
67
|
+
[processor AND (researcher OR fallback)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
HITL Scenario 2 (human-initiated)
|
|
71
|
+
==================================
|
|
72
|
+
Webhook client (human agent)
|
|
73
|
+
"human_contact" ──────> | (no from_agent → route to greeter)
|
|
74
|
+
v
|
|
75
|
+
greeter_agent (LLM)
|
|
76
|
+
| (dual callback → client)
|
|
77
|
+
v
|
|
78
|
+
client (HITL, from_agent = "greeter_agent")
|
|
79
|
+
[outbound webhook → human reviews → inbound webhook]
|
|
80
|
+
| (from_agent == "greeter_agent" → route to processor)
|
|
81
|
+
v
|
|
82
|
+
processor_agent → researcher + fallback → advisor
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## HITL Walkthrough
|
|
86
|
+
|
|
87
|
+
### Prerequisites
|
|
88
|
+
|
|
89
|
+
You need **3 terminals** open:
|
|
90
|
+
|
|
91
|
+
| Terminal | Purpose |
|
|
92
|
+
|----------|---------|
|
|
93
|
+
| Terminal 1 | HITL stub receiver (captures outbound webhooks) |
|
|
94
|
+
| Terminal 2 | LeafMesh server |
|
|
95
|
+
| Terminal 3 | curl commands (trigger mesh + respond as human) |
|
|
96
|
+
|
|
97
|
+
### Step 1: Start the Stub Receiver
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# Terminal 1
|
|
101
|
+
python hitl_stub_receiver.py
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This listens on port 9999 and prints outbound webhook payloads when the human agent is invoked.
|
|
105
|
+
|
|
106
|
+
### Step 2: Start the Mesh
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Terminal 2
|
|
110
|
+
python main.py
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Wait for "{{project_name}} is running" in the logs.
|
|
114
|
+
|
|
115
|
+
### Step 3: Get Your Webhook Signing Secret
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Terminal 3
|
|
119
|
+
curl http://127.0.0.1:18820/api/webhook/secret
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Save the `secret` value — you'll need it to sign inbound webhook responses.
|
|
123
|
+
|
|
124
|
+
### Scenario 1: System-Initiated HITL
|
|
125
|
+
|
|
126
|
+
The system triggers a workflow, and the human agent is called mid-flow for review.
|
|
127
|
+
|
|
128
|
+
**Trigger the mesh:**
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# Terminal 3
|
|
132
|
+
curl -X POST http://127.0.0.1:18820/api/mesh/request \
|
|
133
|
+
-H "Content-Type: application/json" \
|
|
134
|
+
-d '{"entry_point": "greet_user", "data": {"message": "I need help with item1, item2, item3"}}'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**What happens:**
|
|
138
|
+
1. `greeter_agent` processes the message via LLM
|
|
139
|
+
2. `greeter_agent` routes to `client` (human agent)
|
|
140
|
+
3. SDK sends outbound webhook to the stub receiver (Terminal 1)
|
|
141
|
+
4. The stub prints a curl command to respond
|
|
142
|
+
|
|
143
|
+
**Respond as the human** (compute HMAC + timestamp + nonce — required by the SDK):
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# Terminal 3 — sign and respond
|
|
147
|
+
# Replace SESSION_ID with the session_id from the stub output
|
|
148
|
+
# Replace SECRET with the value from /api/webhook/secret
|
|
149
|
+
|
|
150
|
+
BODY='{"session_id": "SESSION_ID", "decision": "approved", "message": "Looks good, proceed with item1, item2, item3"}'
|
|
151
|
+
SECRET="your-secret-here"
|
|
152
|
+
TS=$(date +%s)
|
|
153
|
+
NONCE=$(python3 -c 'import secrets; print(secrets.token_urlsafe(16))')
|
|
154
|
+
|
|
155
|
+
# Signed material: "{timestamp}.{nonce}.{body}"
|
|
156
|
+
SIG=$(printf '%s' "$TS.$NONCE.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
|
|
157
|
+
|
|
158
|
+
curl -X POST http://127.0.0.1:18820/webhook/greet_user \
|
|
159
|
+
-H "Content-Type: application/json" \
|
|
160
|
+
-H "X-LeafMesh-Signature: sha256=$SIG" \
|
|
161
|
+
-H "X-LeafMesh-Timestamp: $TS" \
|
|
162
|
+
-H "X-LeafMesh-Nonce: $NONCE" \
|
|
163
|
+
-d "$BODY"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
**Result:** The human's response flows through `processor_agent` → `researcher_agent` + `fallback_researcher_agent` → `advisor_agent`.
|
|
167
|
+
|
|
168
|
+
### Scenario 2: Human-Initiated HITL
|
|
169
|
+
|
|
170
|
+
The human initiates contact, the system processes it, and the human reviews before final processing.
|
|
171
|
+
|
|
172
|
+
**Trigger via webhook:**
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# Terminal 3
|
|
176
|
+
BODY='{"message": "I want to report an issue with item1, item2"}'
|
|
177
|
+
SECRET="your-secret-here"
|
|
178
|
+
TS=$(date +%s)
|
|
179
|
+
NONCE=$(python3 -c 'import secrets; print(secrets.token_urlsafe(16))')
|
|
180
|
+
SIG=$(printf '%s' "$TS.$NONCE.$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
|
|
181
|
+
|
|
182
|
+
curl -X POST http://127.0.0.1:18820/webhook/human_contact \
|
|
183
|
+
-H "Content-Type: application/json" \
|
|
184
|
+
-H "X-LeafMesh-Signature: sha256=$SIG" \
|
|
185
|
+
-H "X-LeafMesh-Timestamp: $TS" \
|
|
186
|
+
-H "X-LeafMesh-Nonce: $NONCE" \
|
|
187
|
+
-d "$BODY"
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**What happens:**
|
|
191
|
+
1. `client` receives the message (no `from_agent` → routes to `greeter_agent`)
|
|
192
|
+
2. `greeter_agent` processes via LLM and responds back to `client`
|
|
193
|
+
3. SDK sends outbound webhook to the stub receiver (Terminal 1)
|
|
194
|
+
4. The stub prints a curl command to respond
|
|
195
|
+
|
|
196
|
+
**Respond as the human** (same pattern as Scenario 1 — use the session_id from the stub output).
|
|
197
|
+
|
|
198
|
+
**Result:** `from_agent == "greeter_agent"` → routes to `processor_agent` → full chain completes.
|
|
199
|
+
|
|
200
|
+
### How `from_agent` Routing Works
|
|
201
|
+
|
|
202
|
+
The `client` agent's `can_call` uses conditions to determine where to route:
|
|
203
|
+
|
|
204
|
+
```yaml
|
|
205
|
+
can_call:
|
|
206
|
+
- agent: "greeter_agent"
|
|
207
|
+
condition: "not calling_agent_response.from_agent" # No caller → greet first
|
|
208
|
+
- agent: "processor_agent"
|
|
209
|
+
condition: "calling_agent_response.from_agent == 'greeter_agent'" # Greeter done → process
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
When an agent calls the human agent, the SDK stores which agent called (`called_by`) in Redis. When the human responds via webhook, the SDK includes `from_agent` in the output data so `can_call` conditions can route accordingly.
|
|
213
|
+
|
|
214
|
+
## Key Concepts
|
|
215
|
+
|
|
216
|
+
### Agent Types
|
|
217
|
+
|
|
218
|
+
| Type | Description | Example |
|
|
219
|
+
|------|-------------|---------|
|
|
220
|
+
| `human` | Human-in-the-loop via webhook/channel | `client` |
|
|
221
|
+
| `llm` | LLM-powered with prompt + tools | `greeter_agent`, `researcher_agent` |
|
|
222
|
+
| `programmatic` | Pure Python logic, no LLM | `processor_agent`, `scheduler_agent` |
|
|
223
|
+
| `external` | Delegates to CrewAI, LangGraph, etc. | See `external_agents.py` |
|
|
224
|
+
|
|
225
|
+
### Decorators
|
|
226
|
+
|
|
227
|
+
| Decorator | Purpose | Example Agent |
|
|
228
|
+
|-----------|---------|---------------|
|
|
229
|
+
| `@pre_compose(fn1, fn2, ...)` | Pre-process input before LLM call | `greeter_agent` |
|
|
230
|
+
| `@chain(fn1, fn2, ...)` | Sequential post-processing pipeline | `advisor_agent` |
|
|
231
|
+
| `@chain_with_results(fn1, fn2)` | Accumulate results across steps | `researcher_agent` |
|
|
232
|
+
| `@conditional_chain(cond, true_fn, false_fn)` | Branch based on condition | `processor_agent` |
|
|
233
|
+
| `@compose(key=fn, ...)` | Inject shaped data per downstream agent | `advisor_agent` |
|
|
234
|
+
|
|
235
|
+
### Fan-In Patterns (`wait_for`)
|
|
236
|
+
|
|
237
|
+
```yaml
|
|
238
|
+
wait_for: "A AND B" # Wait for both
|
|
239
|
+
wait_for: "A OR B" # First wins
|
|
240
|
+
wait_for: "A AND B?" # A required, B optional
|
|
241
|
+
wait_for: "A AND (B OR C)" # A required + first of B or C
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Smart Memory
|
|
245
|
+
|
|
246
|
+
```yaml
|
|
247
|
+
memory:
|
|
248
|
+
strategy: "hybrid" # recency | relevance | hybrid
|
|
249
|
+
limit: 10 # Max entries per invocation
|
|
250
|
+
cross_session: true # Persist across sessions
|
|
251
|
+
relevance_weight: 0.6
|
|
252
|
+
recency_weight: 0.4
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Upstream Yields
|
|
256
|
+
|
|
257
|
+
Agents define `yields` in YAML. The SDK auto-stores them in Redis and injects them into downstream agents as `input_data["upstream_yields"][agent_name]`.
|
|
258
|
+
|
|
259
|
+
### Scheduled Agents
|
|
260
|
+
|
|
261
|
+
```yaml
|
|
262
|
+
scheduler_agent:
|
|
263
|
+
wake_up: "0 9 * * *" # Cron expression (9 AM UTC daily)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
Also triggerable on-demand via `mesh_call("scheduled_report", data)`.
|
|
267
|
+
|
|
268
|
+
## API Endpoints
|
|
269
|
+
|
|
270
|
+
| Method | Path | Description |
|
|
271
|
+
|--------|------|-------------|
|
|
272
|
+
| POST | `/api/mesh/request` | Trigger agent workflows via entry points |
|
|
273
|
+
| POST | `/api/mesh/stream` | SSE stream of LLM response |
|
|
274
|
+
| POST | `/webhook/{entry_point}` | Webhook (new task or human HITL response) |
|
|
275
|
+
| GET | `/api/mesh/entry_points` | List configured entry points |
|
|
276
|
+
| GET | `/api/agents/` | List all agents |
|
|
277
|
+
| GET | `/api/sessions/` | List active sessions |
|
|
278
|
+
| GET | `/api/sessions/{id}/history` | Conversation history |
|
|
279
|
+
| GET | `/api/webhook/secret` | HMAC signing secret |
|
|
280
|
+
| GET | `/health` | Health check |
|
|
281
|
+
| GET | `/docs` | Interactive API docs (ReDoc) |
|
|
282
|
+
|
|
283
|
+
## Docker
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
# Start Redis + app
|
|
287
|
+
docker compose up --build
|
|
288
|
+
|
|
289
|
+
# Or just Redis (run app locally)
|
|
290
|
+
docker compose up redis -d
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Configuration
|
|
294
|
+
|
|
295
|
+
All agent wiring is in `configs/config.yaml`. Key sections:
|
|
296
|
+
|
|
297
|
+
- **`entry_points`** — Named portals into the mesh
|
|
298
|
+
- **`agents`** — Agent definitions (type, model, can_call, yields, etc.)
|
|
299
|
+
- **`manager`** — Built-in coordinator with learning-based routing and escalation
|
|
300
|
+
- **`mesh`** — Timeout and cloud LLM provider configs
|
|
301
|
+
|
|
302
|
+
## Observability
|
|
303
|
+
|
|
304
|
+
Observability auto-enables when `LEAFMESH_LICENSE_KEY` is set in `.env`. Traces, metrics, and logs flow automatically. Set `LEAFMESH_ENV_TOKEN` to group telemetry by environment.
|
|
305
|
+
|
|
306
|
+
## Links
|
|
307
|
+
|
|
308
|
+
- [LeafMesh SDK](https://pypi.org/project/leafmesh/)
|
|
309
|
+
- [LeafCraft Studios](https://leafcraft.ai)
|
|
File without changes
|