general-augment-cli 0.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.
- general_augment_cli-0.1.0.dist-info/METADATA +180 -0
- general_augment_cli-0.1.0.dist-info/RECORD +42 -0
- general_augment_cli-0.1.0.dist-info/WHEEL +4 -0
- general_augment_cli-0.1.0.dist-info/entry_points.txt +2 -0
- platform_cli/__init__.py +5 -0
- platform_cli/branding.py +27 -0
- platform_cli/client.py +179 -0
- platform_cli/commands/__init__.py +1 -0
- platform_cli/commands/approvals.py +150 -0
- platform_cli/commands/auth.py +96 -0
- platform_cli/commands/billing.py +143 -0
- platform_cli/commands/channels.py +212 -0
- platform_cli/commands/deploy.py +72 -0
- platform_cli/commands/dev.py +38 -0
- platform_cli/commands/doctor.py +170 -0
- platform_cli/commands/identity.py +433 -0
- platform_cli/commands/init.py +55 -0
- platform_cli/commands/integrate.py +94 -0
- platform_cli/commands/keys.py +116 -0
- platform_cli/commands/logs.py +43 -0
- platform_cli/commands/mcp.py +258 -0
- platform_cli/commands/memory.py +316 -0
- platform_cli/commands/mock.py +30 -0
- platform_cli/commands/model_providers.py +226 -0
- platform_cli/commands/observability.py +174 -0
- platform_cli/commands/onboarding.py +72 -0
- platform_cli/commands/projects.py +302 -0
- platform_cli/commands/skills.py +116 -0
- platform_cli/commands/smoke.py +280 -0
- platform_cli/commands/status.py +49 -0
- platform_cli/commands/tools.py +179 -0
- platform_cli/commands/users.py +150 -0
- platform_cli/commands/validate.py +96 -0
- platform_cli/commands/verify.py +648 -0
- platform_cli/config.py +114 -0
- platform_cli/errors.py +103 -0
- platform_cli/local_mock.py +1392 -0
- platform_cli/main.py +130 -0
- platform_cli/openapi.py +1048 -0
- platform_cli/output.py +47 -0
- platform_cli/readiness.py +176 -0
- platform_cli/runtime.py +22 -0
platform_cli/openapi.py
ADDED
|
@@ -0,0 +1,1048 @@
|
|
|
1
|
+
"""Standalone OpenAPI-to-agent scaffold helpers for the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
HTTP_METHODS = {"get", "post", "put", "patch", "delete"}
|
|
16
|
+
PUBLIC_MANIFEST_FILENAME = "genaug-agent.yaml"
|
|
17
|
+
CODING_AGENT_PROMPT_FILENAME = "CODING_AGENT_PROMPT.md"
|
|
18
|
+
PUBLIC_API_VERSION = "genaug/v1"
|
|
19
|
+
MODEL_KEYS = {"simple", "balanced", "complex"}
|
|
20
|
+
VALID_MODEL_PREFIXES = (
|
|
21
|
+
"anthropic/",
|
|
22
|
+
"claude-",
|
|
23
|
+
"gemini-",
|
|
24
|
+
"google/gemini-",
|
|
25
|
+
"openai/",
|
|
26
|
+
)
|
|
27
|
+
TOOL_DISCOVERY_MODES = {"auto", "always", "direct"}
|
|
28
|
+
DEFAULT_TOOL_DISCOVERY: dict[str, int | str] = {
|
|
29
|
+
"mode": "auto",
|
|
30
|
+
"direct_schema_tool_limit": 10,
|
|
31
|
+
"max_search_results": 5,
|
|
32
|
+
}
|
|
33
|
+
SENSITIVE_KEY_MARKERS = ("auth", "authorization", "api_key", "apikey", "key", "secret", "token")
|
|
34
|
+
SECRET_PLACEHOLDER_RE = re.compile(r"\$\{\{\s*(secrets|credentials)\.[A-Za-z0-9_.-]+\s*\}\}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolCandidate(BaseModel):
|
|
38
|
+
"""Generated API tool metadata."""
|
|
39
|
+
|
|
40
|
+
tool_id: str
|
|
41
|
+
name: str
|
|
42
|
+
description: str
|
|
43
|
+
http_method: str
|
|
44
|
+
path: str
|
|
45
|
+
input_schema: dict[str, Any] = Field(default_factory=dict)
|
|
46
|
+
risk_level: str
|
|
47
|
+
requires_approval: bool
|
|
48
|
+
enabled: bool = True
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ParsedAPI(BaseModel):
|
|
52
|
+
"""Parsed OpenAPI specification fields needed by the CLI."""
|
|
53
|
+
|
|
54
|
+
title: str
|
|
55
|
+
version: str
|
|
56
|
+
description: str
|
|
57
|
+
base_url: str
|
|
58
|
+
auth_schemes: list[str]
|
|
59
|
+
tools: list[ToolCandidate]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ScaffoldResult:
|
|
64
|
+
"""Local files generated from an OpenAPI specification."""
|
|
65
|
+
|
|
66
|
+
root: Path
|
|
67
|
+
config_path: Path
|
|
68
|
+
soul_path: Path
|
|
69
|
+
tools_dir: Path
|
|
70
|
+
env_path: Path
|
|
71
|
+
agent_prompt_path: Path
|
|
72
|
+
parsed_api: ParsedAPI
|
|
73
|
+
tools: list[ToolCandidate]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class BasicScaffoldResult:
|
|
78
|
+
"""Local files generated for a starter agent without an OpenAPI spec."""
|
|
79
|
+
|
|
80
|
+
root: Path
|
|
81
|
+
config_path: Path
|
|
82
|
+
soul_path: Path
|
|
83
|
+
skills_dir: Path
|
|
84
|
+
tools_dir: Path
|
|
85
|
+
env_path: Path
|
|
86
|
+
agent_prompt_path: Path
|
|
87
|
+
builtin_tools: list[str]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class LocalValidationResult:
|
|
92
|
+
"""Local validation result for a genaug-agent.yaml manifest."""
|
|
93
|
+
|
|
94
|
+
config_path: Path
|
|
95
|
+
status: str
|
|
96
|
+
project_name: str | None
|
|
97
|
+
errors: list[str]
|
|
98
|
+
warnings: list[str]
|
|
99
|
+
soul_file: Path | None
|
|
100
|
+
skills_dir: Path | None
|
|
101
|
+
skill_count: int
|
|
102
|
+
builtin_tools: list[str]
|
|
103
|
+
mcp_servers: list[str]
|
|
104
|
+
tool_discovery: dict[str, int | str]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_openapi(spec_source: str) -> ParsedAPI:
|
|
108
|
+
"""Parse an OpenAPI spec from URL, file path, or raw JSON/YAML."""
|
|
109
|
+
spec = _load_spec(spec_source)
|
|
110
|
+
raw_info = spec.get("info")
|
|
111
|
+
info: dict[str, Any] = raw_info if isinstance(raw_info, dict) else {}
|
|
112
|
+
raw_servers = spec.get("servers")
|
|
113
|
+
servers: list[Any] = raw_servers if isinstance(raw_servers, list) else []
|
|
114
|
+
first_server = servers[0] if servers and isinstance(servers[0], dict) else {}
|
|
115
|
+
first_server_data: dict[str, Any] = cast(dict[str, Any], first_server)
|
|
116
|
+
title = str(info.get("title") or "API")
|
|
117
|
+
tools = _extract_tools(spec)
|
|
118
|
+
return ParsedAPI(
|
|
119
|
+
title=title,
|
|
120
|
+
version=str(info.get("version") or "1.0.0"),
|
|
121
|
+
description=str(info.get("description") or ""),
|
|
122
|
+
base_url=str(first_server_data.get("url") or ""),
|
|
123
|
+
auth_schemes=_extract_auth_schemes(spec),
|
|
124
|
+
tools=tools,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def auto_curate(tools: list[ToolCandidate], target_count: int) -> list[ToolCandidate]:
|
|
129
|
+
"""Curate generated tools using simple local heuristics."""
|
|
130
|
+
visible = [
|
|
131
|
+
tool
|
|
132
|
+
for tool in tools
|
|
133
|
+
if not any(marker in tool.path.lower() for marker in ("/admin", "/internal", "/debug"))
|
|
134
|
+
]
|
|
135
|
+
ranked = sorted(visible, key=lambda tool: (_risk_rank(tool.risk_level), tool.tool_id))
|
|
136
|
+
curated = ranked[:target_count]
|
|
137
|
+
return [
|
|
138
|
+
tool.model_copy(update={"enabled": tool.risk_level != "high"})
|
|
139
|
+
for tool in curated
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def scaffold_basic_agent(
|
|
144
|
+
*,
|
|
145
|
+
name: str,
|
|
146
|
+
output_dir: Path | None,
|
|
147
|
+
display_name: str | None = None,
|
|
148
|
+
description: str | None = None,
|
|
149
|
+
builtin_tools: list[str] | None = None,
|
|
150
|
+
force: bool = False,
|
|
151
|
+
) -> BasicScaffoldResult:
|
|
152
|
+
"""Generate a deployable starter agent project without requiring an OpenAPI spec."""
|
|
153
|
+
slug = _slugify(name)
|
|
154
|
+
resolved_display_name = display_name or _display_name(name)
|
|
155
|
+
project_description = (
|
|
156
|
+
description
|
|
157
|
+
or f"{resolved_display_name} helps app users complete useful work with memory and tools."
|
|
158
|
+
)
|
|
159
|
+
root = output_dir or (Path.cwd() / f"{slug}-agent")
|
|
160
|
+
config_path = root / PUBLIC_MANIFEST_FILENAME
|
|
161
|
+
soul_path = root / "SOUL.md"
|
|
162
|
+
skills_dir = root / "skills"
|
|
163
|
+
tools_dir = root / "tools"
|
|
164
|
+
env_path = root / ".env.example"
|
|
165
|
+
agent_prompt_path = root / CODING_AGENT_PROMPT_FILENAME
|
|
166
|
+
files = [
|
|
167
|
+
config_path,
|
|
168
|
+
soul_path,
|
|
169
|
+
env_path,
|
|
170
|
+
agent_prompt_path,
|
|
171
|
+
skills_dir / "README.md",
|
|
172
|
+
tools_dir / "README.md",
|
|
173
|
+
]
|
|
174
|
+
existing = [path for path in files if path.exists()]
|
|
175
|
+
if existing and not force:
|
|
176
|
+
names = ", ".join(str(path) for path in existing)
|
|
177
|
+
raise FileExistsError(f"Refusing to overwrite existing files: {names}")
|
|
178
|
+
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
normalized_tools = _normalize_builtin_tools(builtin_tools or [])
|
|
181
|
+
config_path.write_text(
|
|
182
|
+
_agent_yaml(
|
|
183
|
+
slug=slug,
|
|
184
|
+
display_name=resolved_display_name,
|
|
185
|
+
role=f"{resolved_display_name} Agent",
|
|
186
|
+
description=project_description,
|
|
187
|
+
tools=[],
|
|
188
|
+
api_version=PUBLIC_API_VERSION,
|
|
189
|
+
builtin_tools=normalized_tools,
|
|
190
|
+
),
|
|
191
|
+
encoding="utf-8",
|
|
192
|
+
)
|
|
193
|
+
soul_path.write_text(
|
|
194
|
+
_soul_md(
|
|
195
|
+
display_name=resolved_display_name,
|
|
196
|
+
role=f"{resolved_display_name} Agent",
|
|
197
|
+
description=project_description,
|
|
198
|
+
),
|
|
199
|
+
encoding="utf-8",
|
|
200
|
+
)
|
|
201
|
+
env_path.write_text(_env_example([]), encoding="utf-8")
|
|
202
|
+
agent_prompt_path.write_text(
|
|
203
|
+
_coding_agent_prompt(
|
|
204
|
+
slug=slug,
|
|
205
|
+
display_name=resolved_display_name,
|
|
206
|
+
description=project_description,
|
|
207
|
+
),
|
|
208
|
+
encoding="utf-8",
|
|
209
|
+
)
|
|
210
|
+
(skills_dir / "README.md").write_text(
|
|
211
|
+
"# Skills\n\nAdd SKILL.md files here for repeatable tenant workflows.\n",
|
|
212
|
+
encoding="utf-8",
|
|
213
|
+
)
|
|
214
|
+
(tools_dir / "README.md").write_text(
|
|
215
|
+
(
|
|
216
|
+
"# Tools\n\n"
|
|
217
|
+
"Use `genaug tools toggle`, `genaug mcp add`, or `genaug integrate` "
|
|
218
|
+
"to add governed tools after the starter agent is created.\n"
|
|
219
|
+
),
|
|
220
|
+
encoding="utf-8",
|
|
221
|
+
)
|
|
222
|
+
return BasicScaffoldResult(
|
|
223
|
+
root=root,
|
|
224
|
+
config_path=config_path,
|
|
225
|
+
soul_path=soul_path,
|
|
226
|
+
skills_dir=skills_dir,
|
|
227
|
+
tools_dir=tools_dir,
|
|
228
|
+
env_path=env_path,
|
|
229
|
+
agent_prompt_path=agent_prompt_path,
|
|
230
|
+
builtin_tools=normalized_tools,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def validate_local_agent_config(config_path: Path) -> LocalValidationResult:
|
|
235
|
+
"""Validate a local genaug-agent.yaml manifest without calling the hosted API."""
|
|
236
|
+
errors: list[str] = []
|
|
237
|
+
warnings: list[str] = []
|
|
238
|
+
payload = _load_yaml_mapping(config_path, errors)
|
|
239
|
+
project_name: str | None = None
|
|
240
|
+
soul_file: Path | None = None
|
|
241
|
+
skills_dir: Path | None = None
|
|
242
|
+
skill_count = 0
|
|
243
|
+
builtin_tools: list[str] = []
|
|
244
|
+
mcp_servers: list[str] = []
|
|
245
|
+
tool_discovery = dict(DEFAULT_TOOL_DISCOVERY)
|
|
246
|
+
if payload is not None:
|
|
247
|
+
_validate_manifest_identity(payload, errors)
|
|
248
|
+
project_name = _validate_metadata(payload.get("metadata"), errors)
|
|
249
|
+
_validate_model_routes(payload.get("model"), errors, warnings)
|
|
250
|
+
soul_file = _validate_personality(payload.get("personality"), config_path, errors, warnings)
|
|
251
|
+
builtin_tools, mcp_servers = _validate_tools(payload.get("tools"), errors, warnings)
|
|
252
|
+
skills_dir, skill_count = _validate_skills(payload.get("skills"), config_path, warnings)
|
|
253
|
+
tool_discovery = _validate_behavior(payload.get("behavior"), errors)
|
|
254
|
+
_validate_channels(payload.get("channels"), warnings)
|
|
255
|
+
return LocalValidationResult(
|
|
256
|
+
config_path=config_path,
|
|
257
|
+
status="FAIL" if errors else "PASS",
|
|
258
|
+
project_name=project_name,
|
|
259
|
+
errors=errors,
|
|
260
|
+
warnings=warnings,
|
|
261
|
+
soul_file=soul_file,
|
|
262
|
+
skills_dir=skills_dir,
|
|
263
|
+
skill_count=skill_count,
|
|
264
|
+
builtin_tools=builtin_tools,
|
|
265
|
+
mcp_servers=mcp_servers,
|
|
266
|
+
tool_discovery=tool_discovery,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def scaffold_from_openapi(
|
|
271
|
+
spec_source: str,
|
|
272
|
+
*,
|
|
273
|
+
output_dir: Path | None,
|
|
274
|
+
name: str | None,
|
|
275
|
+
description: str | None,
|
|
276
|
+
target_count: int = 15,
|
|
277
|
+
force: bool = False,
|
|
278
|
+
) -> ScaffoldResult:
|
|
279
|
+
"""Generate a deployable local agent project from an OpenAPI spec."""
|
|
280
|
+
parsed = parse_openapi(spec_source)
|
|
281
|
+
tools = auto_curate(parsed.tools, target_count=target_count)
|
|
282
|
+
slug = _slugify(name or parsed.title)
|
|
283
|
+
display_name = _display_name(name or parsed.title)
|
|
284
|
+
root = output_dir or (Path.cwd() / f"{slug}-agent")
|
|
285
|
+
config_path = root / PUBLIC_MANIFEST_FILENAME
|
|
286
|
+
soul_path = root / "SOUL.md"
|
|
287
|
+
tools_dir = root / "tools"
|
|
288
|
+
env_path = root / ".env.example"
|
|
289
|
+
agent_prompt_path = root / CODING_AGENT_PROMPT_FILENAME
|
|
290
|
+
skill_readme = root / "skills" / "README.md"
|
|
291
|
+
tool_paths = [tools_dir / f"{tool.tool_id}.yaml" for tool in tools]
|
|
292
|
+
files = [
|
|
293
|
+
config_path,
|
|
294
|
+
soul_path,
|
|
295
|
+
env_path,
|
|
296
|
+
agent_prompt_path,
|
|
297
|
+
skill_readme,
|
|
298
|
+
*tool_paths,
|
|
299
|
+
]
|
|
300
|
+
existing = [path for path in files if path.exists()]
|
|
301
|
+
if existing and not force:
|
|
302
|
+
names = ", ".join(str(path) for path in existing)
|
|
303
|
+
raise FileExistsError(f"Refusing to overwrite existing files: {names}")
|
|
304
|
+
tools_dir.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
skill_readme.parent.mkdir(parents=True, exist_ok=True)
|
|
306
|
+
project_description = description or parsed.description or f"{display_name} API assistant."
|
|
307
|
+
public_manifest = _agent_yaml(
|
|
308
|
+
slug=slug,
|
|
309
|
+
display_name=display_name,
|
|
310
|
+
role=f"{parsed.title} Assistant",
|
|
311
|
+
description=project_description,
|
|
312
|
+
tools=tools,
|
|
313
|
+
api_version=PUBLIC_API_VERSION,
|
|
314
|
+
)
|
|
315
|
+
config_path.write_text(public_manifest, encoding="utf-8")
|
|
316
|
+
soul_path.write_text(
|
|
317
|
+
_soul_md(
|
|
318
|
+
display_name=display_name,
|
|
319
|
+
role=f"{parsed.title} Assistant",
|
|
320
|
+
description=project_description,
|
|
321
|
+
),
|
|
322
|
+
encoding="utf-8",
|
|
323
|
+
)
|
|
324
|
+
env_path.write_text(_env_example(parsed.auth_schemes), encoding="utf-8")
|
|
325
|
+
agent_prompt_path.write_text(
|
|
326
|
+
_coding_agent_prompt(
|
|
327
|
+
slug=slug,
|
|
328
|
+
display_name=display_name,
|
|
329
|
+
description=project_description,
|
|
330
|
+
),
|
|
331
|
+
encoding="utf-8",
|
|
332
|
+
)
|
|
333
|
+
skill_readme.write_text(
|
|
334
|
+
"# Skills\n\nAdd SKILL.md files here for repeatable workflows.\n",
|
|
335
|
+
encoding="utf-8",
|
|
336
|
+
)
|
|
337
|
+
for tool in tools:
|
|
338
|
+
(tools_dir / f"{tool.tool_id}.yaml").write_text(
|
|
339
|
+
yaml.safe_dump(tool.model_dump(), sort_keys=False),
|
|
340
|
+
encoding="utf-8",
|
|
341
|
+
)
|
|
342
|
+
return ScaffoldResult(
|
|
343
|
+
root=root,
|
|
344
|
+
config_path=config_path,
|
|
345
|
+
soul_path=soul_path,
|
|
346
|
+
tools_dir=tools_dir,
|
|
347
|
+
env_path=env_path,
|
|
348
|
+
agent_prompt_path=agent_prompt_path,
|
|
349
|
+
parsed_api=parsed,
|
|
350
|
+
tools=tools,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def load_deploy_payload(config_path: Path) -> dict[str, Any]:
|
|
355
|
+
"""Validate and load local agent config plus optional SOUL.md and skills."""
|
|
356
|
+
validation = validate_local_agent_config(config_path)
|
|
357
|
+
if validation.errors:
|
|
358
|
+
raise ValueError(f"Agent manifest validation failed: {'; '.join(validation.errors)}")
|
|
359
|
+
yaml_content = config_path.read_text(encoding="utf-8")
|
|
360
|
+
payload = yaml.safe_load(yaml_content)
|
|
361
|
+
if not isinstance(payload, dict):
|
|
362
|
+
raise ValueError("Agent manifest must contain a YAML object.")
|
|
363
|
+
if payload.get("apiVersion") != PUBLIC_API_VERSION or payload.get("kind") != "Agent":
|
|
364
|
+
raise ValueError(
|
|
365
|
+
"Agent manifest must use apiVersion genaug/v1 and kind Agent."
|
|
366
|
+
)
|
|
367
|
+
metadata = payload.get("metadata")
|
|
368
|
+
if not isinstance(metadata, dict) or not metadata.get("name"):
|
|
369
|
+
raise ValueError("Agent manifest metadata.name is required.")
|
|
370
|
+
|
|
371
|
+
personality = payload.get("personality") if isinstance(payload.get("personality"), dict) else {}
|
|
372
|
+
soul_content = None
|
|
373
|
+
soul_file = personality.get("soul_file") if isinstance(personality, dict) else None
|
|
374
|
+
if isinstance(soul_file, str) and soul_file:
|
|
375
|
+
soul_path = (config_path.parent / soul_file).resolve()
|
|
376
|
+
soul_content = soul_path.read_text(encoding="utf-8")
|
|
377
|
+
|
|
378
|
+
skills: list[str] = []
|
|
379
|
+
skills_block = payload.get("skills") if isinstance(payload.get("skills"), dict) else {}
|
|
380
|
+
skills_dir = skills_block.get("directory") if isinstance(skills_block, dict) else None
|
|
381
|
+
if isinstance(skills_dir, str) and skills_dir:
|
|
382
|
+
root = (config_path.parent / skills_dir).resolve()
|
|
383
|
+
if root.exists():
|
|
384
|
+
skills = [
|
|
385
|
+
path.read_text(encoding="utf-8")
|
|
386
|
+
for path in sorted(root.rglob("*.md"))
|
|
387
|
+
if path.name.upper() == "SKILL.MD"
|
|
388
|
+
]
|
|
389
|
+
return {
|
|
390
|
+
"yaml_content": yaml_content,
|
|
391
|
+
"soul_content": soul_content,
|
|
392
|
+
"skills": skills,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def project_name_from_config(config_path: Path) -> str:
|
|
397
|
+
"""Return metadata.name from a local config."""
|
|
398
|
+
payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
399
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("metadata"), dict):
|
|
400
|
+
raise ValueError("Agent manifest metadata.name is required.")
|
|
401
|
+
name = payload["metadata"].get("name")
|
|
402
|
+
if not isinstance(name, str) or not name:
|
|
403
|
+
raise ValueError("Agent manifest metadata.name is required.")
|
|
404
|
+
return name
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _load_yaml_mapping(config_path: Path, errors: list[str]) -> dict[str, Any] | None:
|
|
408
|
+
"""Load a YAML object for local validation."""
|
|
409
|
+
try:
|
|
410
|
+
payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
411
|
+
except yaml.YAMLError as exc:
|
|
412
|
+
errors.append(f"Invalid YAML: {exc}")
|
|
413
|
+
return None
|
|
414
|
+
except OSError as exc:
|
|
415
|
+
errors.append(f"Could not read manifest: {exc}")
|
|
416
|
+
return None
|
|
417
|
+
if not isinstance(payload, dict):
|
|
418
|
+
errors.append("Agent manifest must contain a YAML object.")
|
|
419
|
+
return None
|
|
420
|
+
return payload
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _validate_manifest_identity(payload: dict[str, Any], errors: list[str]) -> None:
|
|
424
|
+
"""Validate top-level manifest identity fields."""
|
|
425
|
+
if payload.get("apiVersion") != PUBLIC_API_VERSION or payload.get("kind") != "Agent":
|
|
426
|
+
errors.append("Agent manifest must use apiVersion genaug/v1 and kind Agent.")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _validate_metadata(metadata: object, errors: list[str]) -> str | None:
|
|
430
|
+
"""Validate metadata and return the project name."""
|
|
431
|
+
if not isinstance(metadata, dict):
|
|
432
|
+
errors.append("metadata must be an object.")
|
|
433
|
+
return None
|
|
434
|
+
raw_name = metadata.get("name")
|
|
435
|
+
if not isinstance(raw_name, str) or not raw_name.strip():
|
|
436
|
+
errors.append("metadata.name is required.")
|
|
437
|
+
return None
|
|
438
|
+
if not _slugify(raw_name):
|
|
439
|
+
errors.append("metadata.name must contain at least one alphanumeric character.")
|
|
440
|
+
display_name = metadata.get("display_name")
|
|
441
|
+
if display_name is not None and not isinstance(display_name, str):
|
|
442
|
+
errors.append("metadata.display_name must be a string when provided.")
|
|
443
|
+
return raw_name
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _validate_model_routes(model: object, errors: list[str], warnings: list[str]) -> None:
|
|
447
|
+
"""Validate local model tier route declarations."""
|
|
448
|
+
if model is None:
|
|
449
|
+
warnings.append("model block is missing; server defaults will apply.")
|
|
450
|
+
return
|
|
451
|
+
if not isinstance(model, dict):
|
|
452
|
+
errors.append("model must be an object with simple, balanced, and complex slots.")
|
|
453
|
+
return
|
|
454
|
+
model_keys = {str(key) for key in model}
|
|
455
|
+
unknown_model_keys = sorted(model_keys - MODEL_KEYS)
|
|
456
|
+
missing_model_keys = sorted(MODEL_KEYS - model_keys)
|
|
457
|
+
if unknown_model_keys:
|
|
458
|
+
errors.append(f"Unknown model slots: {', '.join(unknown_model_keys)}")
|
|
459
|
+
if missing_model_keys:
|
|
460
|
+
errors.append(f"Missing model slots: {', '.join(missing_model_keys)}")
|
|
461
|
+
for slot, model_name in sorted(model.items()):
|
|
462
|
+
if not isinstance(model_name, str) or not _valid_model_name(model_name):
|
|
463
|
+
errors.append(f"Invalid model for {slot}: {model_name}")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _validate_personality(
|
|
467
|
+
personality: object,
|
|
468
|
+
config_path: Path,
|
|
469
|
+
errors: list[str],
|
|
470
|
+
warnings: list[str],
|
|
471
|
+
) -> Path | None:
|
|
472
|
+
"""Validate personality references and return a resolved SOUL path when present."""
|
|
473
|
+
if personality is None:
|
|
474
|
+
warnings.append("personality block is missing; hosted defaults will apply.")
|
|
475
|
+
return None
|
|
476
|
+
if not isinstance(personality, dict):
|
|
477
|
+
errors.append("personality must be an object.")
|
|
478
|
+
return None
|
|
479
|
+
raw_soul_file = personality.get("soul_file")
|
|
480
|
+
description = personality.get("description")
|
|
481
|
+
if raw_soul_file is None:
|
|
482
|
+
if not isinstance(description, str) or not description.strip():
|
|
483
|
+
warnings.append("No personality.soul_file or personality.description was provided.")
|
|
484
|
+
return None
|
|
485
|
+
if not isinstance(raw_soul_file, str) or not raw_soul_file.strip():
|
|
486
|
+
errors.append("personality.soul_file must be a non-empty string when provided.")
|
|
487
|
+
return None
|
|
488
|
+
soul_path = (config_path.parent / raw_soul_file).resolve()
|
|
489
|
+
if not soul_path.is_file():
|
|
490
|
+
errors.append(f"personality.soul_file was not found: {raw_soul_file}")
|
|
491
|
+
return soul_path
|
|
492
|
+
return soul_path
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _validate_tools(
|
|
496
|
+
tools: object,
|
|
497
|
+
errors: list[str],
|
|
498
|
+
warnings: list[str],
|
|
499
|
+
) -> tuple[list[str], list[str]]:
|
|
500
|
+
"""Validate builtin and MCP tool declarations."""
|
|
501
|
+
if tools is None:
|
|
502
|
+
warnings.append("tools block is missing; no tools will be enabled by this manifest.")
|
|
503
|
+
return [], []
|
|
504
|
+
if not isinstance(tools, dict):
|
|
505
|
+
errors.append("tools must be an object.")
|
|
506
|
+
return [], []
|
|
507
|
+
builtin = _string_list(tools.get("builtin"), field_name="tools.builtin", errors=errors)
|
|
508
|
+
duplicates = sorted({tool for tool in builtin if builtin.count(tool) > 1})
|
|
509
|
+
if duplicates:
|
|
510
|
+
warnings.append(f"Duplicate builtin tools should be reviewed: {', '.join(duplicates)}")
|
|
511
|
+
raw_mcp = tools.get("mcp")
|
|
512
|
+
if raw_mcp is None:
|
|
513
|
+
return builtin, []
|
|
514
|
+
if not isinstance(raw_mcp, list):
|
|
515
|
+
errors.append("tools.mcp must be a list.")
|
|
516
|
+
return builtin, []
|
|
517
|
+
server_names: list[str] = []
|
|
518
|
+
for index, server in enumerate(raw_mcp):
|
|
519
|
+
if not isinstance(server, dict):
|
|
520
|
+
errors.append(f"tools.mcp[{index}] must be an object.")
|
|
521
|
+
continue
|
|
522
|
+
server_name = _validate_mcp_server(server, index, errors)
|
|
523
|
+
if server_name:
|
|
524
|
+
server_names.append(server_name)
|
|
525
|
+
return builtin, server_names
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _validate_mcp_server(server: dict[str, Any], index: int, errors: list[str]) -> str | None:
|
|
529
|
+
"""Validate one local MCP server declaration."""
|
|
530
|
+
name = server.get("name")
|
|
531
|
+
if not isinstance(name, str) or not name.strip():
|
|
532
|
+
errors.append(f"tools.mcp[{index}].name is required.")
|
|
533
|
+
server_name = None
|
|
534
|
+
else:
|
|
535
|
+
server_name = name
|
|
536
|
+
has_url = bool(server.get("url"))
|
|
537
|
+
has_command = bool(server.get("command"))
|
|
538
|
+
if has_url == has_command:
|
|
539
|
+
errors.append(f"tools.mcp[{index}] must define exactly one of url or command.")
|
|
540
|
+
auth = server.get("auth")
|
|
541
|
+
if auth is not None and (
|
|
542
|
+
not isinstance(auth, str) or not _contains_secret_placeholder(auth)
|
|
543
|
+
):
|
|
544
|
+
errors.append(
|
|
545
|
+
f"tools.mcp[{index}].auth must use a credential placeholder such as "
|
|
546
|
+
"${{ secrets.NAME }} or ${{ credentials.name }}."
|
|
547
|
+
)
|
|
548
|
+
_validate_secret_mapping(server.get("headers"), f"tools.mcp[{index}].headers", errors)
|
|
549
|
+
_validate_secret_mapping(server.get("env"), f"tools.mcp[{index}].env", errors)
|
|
550
|
+
_validate_mcp_tool_filters(server.get("tools"), index, errors)
|
|
551
|
+
return server_name
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _validate_secret_mapping(value: object, field_name: str, errors: list[str]) -> None:
|
|
555
|
+
"""Validate sensitive header/env values use placeholders."""
|
|
556
|
+
if value is None:
|
|
557
|
+
return
|
|
558
|
+
if not isinstance(value, dict):
|
|
559
|
+
errors.append(f"{field_name} must be an object.")
|
|
560
|
+
return
|
|
561
|
+
for raw_key, raw_value in value.items():
|
|
562
|
+
key = str(raw_key)
|
|
563
|
+
if not isinstance(raw_value, str):
|
|
564
|
+
errors.append(f"{field_name}.{key} must be a string.")
|
|
565
|
+
continue
|
|
566
|
+
if _is_sensitive_key(key) and raw_value and not _contains_secret_placeholder(raw_value):
|
|
567
|
+
errors.append(
|
|
568
|
+
f"{field_name}.{key} must use a credential placeholder, not a raw secret."
|
|
569
|
+
)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _validate_mcp_tool_filters(value: object, index: int, errors: list[str]) -> None:
|
|
573
|
+
"""Validate optional MCP include/exclude lists."""
|
|
574
|
+
if value is None:
|
|
575
|
+
return
|
|
576
|
+
if not isinstance(value, dict):
|
|
577
|
+
errors.append(f"tools.mcp[{index}].tools must be an object.")
|
|
578
|
+
return
|
|
579
|
+
for filter_name in ("include", "exclude"):
|
|
580
|
+
if filter_name in value:
|
|
581
|
+
_string_list(
|
|
582
|
+
value.get(filter_name),
|
|
583
|
+
field_name=f"tools.mcp[{index}].tools.{filter_name}",
|
|
584
|
+
errors=errors,
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _validate_skills(
|
|
589
|
+
skills: object,
|
|
590
|
+
config_path: Path,
|
|
591
|
+
warnings: list[str],
|
|
592
|
+
) -> tuple[Path | None, int]:
|
|
593
|
+
"""Validate skills directory and return resolved directory plus SKILL.md count."""
|
|
594
|
+
if skills is None:
|
|
595
|
+
warnings.append("skills block is missing; no local skills will be deployed.")
|
|
596
|
+
return None, 0
|
|
597
|
+
if not isinstance(skills, dict):
|
|
598
|
+
warnings.append("skills block is not an object; no local skills were counted.")
|
|
599
|
+
return None, 0
|
|
600
|
+
raw_directory = skills.get("directory")
|
|
601
|
+
if not isinstance(raw_directory, str) or not raw_directory.strip():
|
|
602
|
+
warnings.append("skills.directory is missing; no local skills will be deployed.")
|
|
603
|
+
return None, 0
|
|
604
|
+
skills_dir = (config_path.parent / raw_directory).resolve()
|
|
605
|
+
if not skills_dir.exists():
|
|
606
|
+
warnings.append(f"skills.directory was not found: {raw_directory}")
|
|
607
|
+
return skills_dir, 0
|
|
608
|
+
if not skills_dir.is_dir():
|
|
609
|
+
warnings.append(f"skills.directory is not a directory: {raw_directory}")
|
|
610
|
+
return skills_dir, 0
|
|
611
|
+
skill_count = sum(1 for path in skills_dir.rglob("SKILL.md") if path.is_file())
|
|
612
|
+
return skills_dir, skill_count
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _validate_behavior(behavior: object, errors: list[str]) -> dict[str, int | str]:
|
|
616
|
+
"""Validate behavior controls and return normalized tool discovery."""
|
|
617
|
+
if behavior is None:
|
|
618
|
+
return dict(DEFAULT_TOOL_DISCOVERY)
|
|
619
|
+
if not isinstance(behavior, dict):
|
|
620
|
+
errors.append("behavior must be an object.")
|
|
621
|
+
return dict(DEFAULT_TOOL_DISCOVERY)
|
|
622
|
+
for field_name in (
|
|
623
|
+
"max_tool_calls_per_turn",
|
|
624
|
+
"session_timeout_minutes",
|
|
625
|
+
"messages_per_user_per_minute",
|
|
626
|
+
):
|
|
627
|
+
if field_name in behavior and not _positive_int_value(behavior.get(field_name)):
|
|
628
|
+
errors.append(f"behavior.{field_name} must be a positive integer.")
|
|
629
|
+
if "daily_token_budget_usd" in behavior:
|
|
630
|
+
budget = behavior.get("daily_token_budget_usd")
|
|
631
|
+
if isinstance(budget, bool) or not isinstance(budget, int | float) or budget < 0:
|
|
632
|
+
errors.append("behavior.daily_token_budget_usd must be a non-negative number.")
|
|
633
|
+
return _validate_tool_discovery(behavior.get("tool_discovery"), errors)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _validate_tool_discovery(value: object, errors: list[str]) -> dict[str, int | str]:
|
|
637
|
+
"""Validate local tool discovery config."""
|
|
638
|
+
if value is None:
|
|
639
|
+
return dict(DEFAULT_TOOL_DISCOVERY)
|
|
640
|
+
if not isinstance(value, dict):
|
|
641
|
+
errors.append("behavior.tool_discovery must be an object.")
|
|
642
|
+
return dict(DEFAULT_TOOL_DISCOVERY)
|
|
643
|
+
mode = str(value.get("mode") or DEFAULT_TOOL_DISCOVERY["mode"]).casefold()
|
|
644
|
+
if mode not in TOOL_DISCOVERY_MODES:
|
|
645
|
+
errors.append("behavior.tool_discovery.mode must be one of: auto, always, direct.")
|
|
646
|
+
mode = str(DEFAULT_TOOL_DISCOVERY["mode"])
|
|
647
|
+
direct_limit = _positive_int_or_default(
|
|
648
|
+
value.get("direct_schema_tool_limit"),
|
|
649
|
+
default=int(DEFAULT_TOOL_DISCOVERY["direct_schema_tool_limit"]),
|
|
650
|
+
field_name="behavior.tool_discovery.direct_schema_tool_limit",
|
|
651
|
+
errors=errors,
|
|
652
|
+
)
|
|
653
|
+
max_results = _positive_int_or_default(
|
|
654
|
+
value.get("max_search_results"),
|
|
655
|
+
default=int(DEFAULT_TOOL_DISCOVERY["max_search_results"]),
|
|
656
|
+
field_name="behavior.tool_discovery.max_search_results",
|
|
657
|
+
errors=errors,
|
|
658
|
+
)
|
|
659
|
+
if max_results > 10:
|
|
660
|
+
errors.append(
|
|
661
|
+
"behavior.tool_discovery.max_search_results must be less than or equal to 10."
|
|
662
|
+
)
|
|
663
|
+
max_results = 10
|
|
664
|
+
return {
|
|
665
|
+
"mode": mode,
|
|
666
|
+
"direct_schema_tool_limit": direct_limit,
|
|
667
|
+
"max_search_results": max_results,
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _validate_channels(channels: object, warnings: list[str]) -> None:
|
|
672
|
+
"""Warn when channel config is omitted entirely."""
|
|
673
|
+
if channels is None:
|
|
674
|
+
warnings.append("channels block is missing; channel setup can be added later.")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _load_spec(spec_source: str) -> dict[str, Any]:
|
|
678
|
+
"""Load an OpenAPI document."""
|
|
679
|
+
if spec_source.startswith(("http://", "https://")):
|
|
680
|
+
response = httpx.get(spec_source, timeout=30.0)
|
|
681
|
+
response.raise_for_status()
|
|
682
|
+
text = response.text
|
|
683
|
+
else:
|
|
684
|
+
path = Path(spec_source).expanduser()
|
|
685
|
+
text = path.read_text(encoding="utf-8") if path.exists() else spec_source
|
|
686
|
+
try:
|
|
687
|
+
payload = json.loads(text)
|
|
688
|
+
except json.JSONDecodeError:
|
|
689
|
+
payload = yaml.safe_load(text)
|
|
690
|
+
if not isinstance(payload, dict) or "paths" not in payload:
|
|
691
|
+
raise ValueError("OpenAPI document must contain a paths object.")
|
|
692
|
+
return payload
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _extract_tools(spec: dict[str, Any]) -> list[ToolCandidate]:
|
|
696
|
+
"""Extract OpenAPI operations as tool candidates."""
|
|
697
|
+
paths = spec.get("paths")
|
|
698
|
+
if not isinstance(paths, dict):
|
|
699
|
+
return []
|
|
700
|
+
tools: list[ToolCandidate] = []
|
|
701
|
+
for path, path_item in paths.items():
|
|
702
|
+
if not isinstance(path_item, dict):
|
|
703
|
+
continue
|
|
704
|
+
for method, operation in path_item.items():
|
|
705
|
+
method_lower = str(method).lower()
|
|
706
|
+
if method_lower not in HTTP_METHODS or not isinstance(operation, dict):
|
|
707
|
+
continue
|
|
708
|
+
risk_level, requires_approval = _classify_risk(method_lower)
|
|
709
|
+
tool_id = _sanitize_tool_id(
|
|
710
|
+
str(operation.get("operationId") or ""),
|
|
711
|
+
method_lower,
|
|
712
|
+
str(path),
|
|
713
|
+
)
|
|
714
|
+
tools.append(
|
|
715
|
+
ToolCandidate(
|
|
716
|
+
tool_id=tool_id,
|
|
717
|
+
name=str(operation.get("summary") or _display_name(tool_id)),
|
|
718
|
+
description=str(
|
|
719
|
+
operation.get("description")
|
|
720
|
+
or operation.get("summary")
|
|
721
|
+
or f"{method_lower.upper()} {path}"
|
|
722
|
+
),
|
|
723
|
+
http_method=method_lower.upper(),
|
|
724
|
+
path=str(path),
|
|
725
|
+
input_schema=_input_schema(path_item, operation),
|
|
726
|
+
risk_level=risk_level,
|
|
727
|
+
requires_approval=requires_approval,
|
|
728
|
+
enabled=risk_level != "high",
|
|
729
|
+
)
|
|
730
|
+
)
|
|
731
|
+
return tools
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _input_schema(path_item: dict[str, Any], operation: dict[str, Any]) -> dict[str, Any]:
|
|
735
|
+
"""Build a JSON schema for one operation input."""
|
|
736
|
+
properties: dict[str, Any] = {}
|
|
737
|
+
required: list[str] = []
|
|
738
|
+
parameters = [
|
|
739
|
+
*[item for item in path_item.get("parameters", []) if isinstance(item, dict)],
|
|
740
|
+
*[item for item in operation.get("parameters", []) if isinstance(item, dict)],
|
|
741
|
+
]
|
|
742
|
+
for parameter in parameters:
|
|
743
|
+
name = parameter.get("name")
|
|
744
|
+
if not isinstance(name, str):
|
|
745
|
+
continue
|
|
746
|
+
raw_schema = parameter.get("schema")
|
|
747
|
+
schema: dict[str, Any] = raw_schema if isinstance(raw_schema, dict) else {}
|
|
748
|
+
properties[name] = {
|
|
749
|
+
**schema,
|
|
750
|
+
"description": str(
|
|
751
|
+
parameter.get("description") or f"{parameter.get('in', 'parameter')} parameter"
|
|
752
|
+
),
|
|
753
|
+
}
|
|
754
|
+
if parameter.get("required") or parameter.get("in") == "path":
|
|
755
|
+
required.append(name)
|
|
756
|
+
request_body = operation.get("requestBody")
|
|
757
|
+
if isinstance(request_body, dict):
|
|
758
|
+
request_body_data: dict[str, Any] = request_body
|
|
759
|
+
raw_content = request_body_data.get("content")
|
|
760
|
+
content = (
|
|
761
|
+
raw_content if isinstance(raw_content, dict) else {}
|
|
762
|
+
)
|
|
763
|
+
content_data: dict[str, Any] = content
|
|
764
|
+
raw_json_body = content_data.get("application/json")
|
|
765
|
+
json_body = (
|
|
766
|
+
raw_json_body if isinstance(raw_json_body, dict) else {}
|
|
767
|
+
)
|
|
768
|
+
json_body_data: dict[str, Any] = json_body
|
|
769
|
+
raw_body_schema = json_body_data.get("schema")
|
|
770
|
+
body_schema: dict[str, Any] = (
|
|
771
|
+
raw_body_schema if isinstance(raw_body_schema, dict) else {}
|
|
772
|
+
)
|
|
773
|
+
properties["body"] = {"type": "object", "description": "JSON request body", **body_schema}
|
|
774
|
+
if request_body_data.get("required"):
|
|
775
|
+
required.append("body")
|
|
776
|
+
return {
|
|
777
|
+
"type": "object",
|
|
778
|
+
"properties": properties,
|
|
779
|
+
"required": sorted(set(required)),
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _extract_auth_schemes(spec: dict[str, Any]) -> list[str]:
|
|
784
|
+
"""Extract readable auth scheme summaries."""
|
|
785
|
+
raw_components = spec.get("components")
|
|
786
|
+
components: dict[str, Any] = raw_components if isinstance(raw_components, dict) else {}
|
|
787
|
+
raw_schemes = components.get("securitySchemes")
|
|
788
|
+
schemes: dict[str, Any] = raw_schemes if isinstance(raw_schemes, dict) else {}
|
|
789
|
+
result = []
|
|
790
|
+
for name, scheme in schemes.items():
|
|
791
|
+
if isinstance(scheme, dict):
|
|
792
|
+
result.append(f"{name}: {scheme.get('type', 'unknown')}")
|
|
793
|
+
return result
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def _classify_risk(method: str) -> tuple[str, bool]:
|
|
797
|
+
"""Return risk level and approval requirement."""
|
|
798
|
+
if method == "get":
|
|
799
|
+
return "low", False
|
|
800
|
+
if method == "delete":
|
|
801
|
+
return "high", True
|
|
802
|
+
return "medium", True
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _sanitize_tool_id(operation_id: str, method: str, path: str) -> str:
|
|
806
|
+
"""Return a valid, stable tool id."""
|
|
807
|
+
source = operation_id or f"{method}_{path.strip('/')}"
|
|
808
|
+
tool_id = re.sub(r"[^a-zA-Z0-9_-]+", "_", source).strip("_-").lower()
|
|
809
|
+
return (tool_id or "tool")[:64]
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _agent_yaml(
|
|
813
|
+
*,
|
|
814
|
+
slug: str,
|
|
815
|
+
display_name: str,
|
|
816
|
+
role: str,
|
|
817
|
+
description: str,
|
|
818
|
+
tools: list[ToolCandidate],
|
|
819
|
+
api_version: str,
|
|
820
|
+
builtin_tools: list[str] | None = None,
|
|
821
|
+
) -> str:
|
|
822
|
+
"""Create a deployable agent manifest document."""
|
|
823
|
+
enabled_tools = [tool.tool_id for tool in tools if tool.enabled]
|
|
824
|
+
mcp_tools = [
|
|
825
|
+
{
|
|
826
|
+
"name": f"{slug}-api",
|
|
827
|
+
"url": f"http://localhost:9090/{slug}",
|
|
828
|
+
"tools": {"include": enabled_tools},
|
|
829
|
+
}
|
|
830
|
+
] if enabled_tools else []
|
|
831
|
+
payload = {
|
|
832
|
+
"apiVersion": api_version,
|
|
833
|
+
"kind": "Agent",
|
|
834
|
+
"metadata": {"name": slug, "display_name": display_name, "version": "1.0.0"},
|
|
835
|
+
"personality": {
|
|
836
|
+
"role": role,
|
|
837
|
+
"soul_file": "./SOUL.md",
|
|
838
|
+
"description": description,
|
|
839
|
+
"tone": "concise, proactive, careful",
|
|
840
|
+
"rules": ["Confirm before write actions.", "Explain missing account links plainly."],
|
|
841
|
+
},
|
|
842
|
+
"model": {
|
|
843
|
+
"simple": "google/gemini-2.5-flash-lite",
|
|
844
|
+
"balanced": "google/gemini-2.5-flash",
|
|
845
|
+
"complex": "google/gemini-2.5-pro",
|
|
846
|
+
},
|
|
847
|
+
"tools": {
|
|
848
|
+
"builtin": list(builtin_tools or []),
|
|
849
|
+
"mcp": mcp_tools,
|
|
850
|
+
},
|
|
851
|
+
"skills": {"directory": "./skills/", "learning_enabled": True},
|
|
852
|
+
"channels": {"whatsapp": {}, "sms": {}, "telegram": {}},
|
|
853
|
+
"behavior": {
|
|
854
|
+
"max_tool_calls_per_turn": 10,
|
|
855
|
+
"session_timeout_minutes": 30,
|
|
856
|
+
"daily_token_budget_usd": 50.0,
|
|
857
|
+
"messages_per_user_per_minute": 30,
|
|
858
|
+
"tool_discovery": {
|
|
859
|
+
"mode": "auto",
|
|
860
|
+
"direct_schema_tool_limit": 10,
|
|
861
|
+
"max_search_results": 5,
|
|
862
|
+
},
|
|
863
|
+
},
|
|
864
|
+
"welcome": {"message": f"Hi, I'm {display_name}. How can I help?"},
|
|
865
|
+
}
|
|
866
|
+
return yaml.safe_dump(payload, sort_keys=False)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _soul_md(*, display_name: str, role: str, description: str) -> str:
|
|
870
|
+
"""Create SOUL.md content."""
|
|
871
|
+
return f"""---
|
|
872
|
+
name: {display_name}
|
|
873
|
+
role: {role}
|
|
874
|
+
tone: concise, proactive, careful
|
|
875
|
+
---
|
|
876
|
+
|
|
877
|
+
# {display_name}
|
|
878
|
+
|
|
879
|
+
{description}
|
|
880
|
+
|
|
881
|
+
## Rules
|
|
882
|
+
|
|
883
|
+
- Confirm before changing user data.
|
|
884
|
+
- Keep chat responses concise.
|
|
885
|
+
- Ask users to link their account when identity is missing.
|
|
886
|
+
"""
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _env_example(auth_schemes: list[str]) -> str:
|
|
890
|
+
"""Create .env.example content for generated integrations."""
|
|
891
|
+
lines = [
|
|
892
|
+
"GENAUG_ADMIN_API_KEY=",
|
|
893
|
+
"GENAUG_API_BASE_URL=https://api.generalaugment.com",
|
|
894
|
+
]
|
|
895
|
+
for scheme in auth_schemes:
|
|
896
|
+
key = re.sub(r"[^A-Z0-9]+", "_", scheme.upper()).strip("_")
|
|
897
|
+
lines.append(f"{key or 'API_AUTH'}=")
|
|
898
|
+
return "\n".join(dict.fromkeys(lines)) + "\n"
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _coding_agent_prompt(*, slug: str, display_name: str, description: str) -> str:
|
|
902
|
+
"""Create a paste-ready coding agent handoff for app developers."""
|
|
903
|
+
return f"""# Coding Agent Handoff
|
|
904
|
+
|
|
905
|
+
Paste this into the coding agent that owns your app backend.
|
|
906
|
+
|
|
907
|
+
```text
|
|
908
|
+
You are integrating our app backend with General Augment.
|
|
909
|
+
|
|
910
|
+
Goal:
|
|
911
|
+
- Keep General Augment API keys server-side only.
|
|
912
|
+
- Use our app's stable signed-in user id as the Responses API `user` value.
|
|
913
|
+
- Call `POST /v1/responses` from the backend, never from browser or mobile code.
|
|
914
|
+
- Add app APIs as governed tools only through approved OpenAPI or MCP registration.
|
|
915
|
+
- Keep write actions approval-required and destructive actions disabled until reviewed.
|
|
916
|
+
- Prove the setup with CLI smoke and verify before production traffic.
|
|
917
|
+
|
|
918
|
+
Project:
|
|
919
|
+
- General Augment project slug: {slug}
|
|
920
|
+
- Agent display name: {display_name}
|
|
921
|
+
- Agent purpose: {description}
|
|
922
|
+
|
|
923
|
+
Required environment variables:
|
|
924
|
+
- GENAUG_API_BASE_URL=https://api.generalaugment.com
|
|
925
|
+
- GENAUG_API_KEY=<project-api-key-from-dashboard-or-cli>
|
|
926
|
+
|
|
927
|
+
Implementation steps:
|
|
928
|
+
1. Install or run the CLI:
|
|
929
|
+
pip install --upgrade general-augment-cli
|
|
930
|
+
genaug --version
|
|
931
|
+
# Private-beta repo fallback if the package is not available yet:
|
|
932
|
+
uv run --project packages/cli genaug --version
|
|
933
|
+
# Use `genaug` below for an installed CLI, or prefix commands with
|
|
934
|
+
# `uv run --project packages/cli` from the repo checkout.
|
|
935
|
+
2. Authenticate and diagnose:
|
|
936
|
+
genaug auth login --api-key "$GENAUG_API_KEY" --base-url "$GENAUG_API_BASE_URL"
|
|
937
|
+
genaug doctor --json
|
|
938
|
+
genaug auth whoami
|
|
939
|
+
3. Review this scaffold:
|
|
940
|
+
- genaug-agent.yaml
|
|
941
|
+
- SOUL.md
|
|
942
|
+
- skills/
|
|
943
|
+
- tools/
|
|
944
|
+
4. Deploy the scaffold:
|
|
945
|
+
genaug deploy ./genaug-agent.yaml
|
|
946
|
+
5. Wire the backend helper:
|
|
947
|
+
- POST "$GENAUG_API_BASE_URL/v1/responses"
|
|
948
|
+
- Authorization: Bearer $GENAUG_API_KEY
|
|
949
|
+
- Body includes model, user, input, metadata.feature, and metadata.trace_id.
|
|
950
|
+
- Store returned response id and metadata.general_augment_trace_id in app logs.
|
|
951
|
+
6. Add explicit memory only for durable facts:
|
|
952
|
+
- POST /api/v1/agent/memory/store with user_id matching the Responses `user`.
|
|
953
|
+
- Search/profile/delete memory through the server-side project key only.
|
|
954
|
+
7. Verify before launch:
|
|
955
|
+
genaug smoke --project {slug} --message "Reply exactly with: ok" --json
|
|
956
|
+
genaug verify --project {slug} --json
|
|
957
|
+
genaug onboarding verify --project {slug} --json
|
|
958
|
+
|
|
959
|
+
Do not:
|
|
960
|
+
- Commit API keys.
|
|
961
|
+
- Put General Augment keys in client-side code.
|
|
962
|
+
- Send secrets in request metadata, memory facts, SOUL.md, skills, or tool definitions.
|
|
963
|
+
- Enable destructive tools until product approval UX exists.
|
|
964
|
+
|
|
965
|
+
Return a final ready/blocked report with exact commands run, response id, trace id,
|
|
966
|
+
dashboard links, CLI/API versions, rate-limit or budget warnings, and any missing auth,
|
|
967
|
+
keys, network, provider, memory, trace, or dashboard setup.
|
|
968
|
+
```
|
|
969
|
+
"""
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _risk_rank(risk_level: str) -> int:
|
|
973
|
+
"""Rank tools for auto-curation."""
|
|
974
|
+
return {"low": 0, "medium": 1, "high": 2}.get(risk_level, 3)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _slugify(value: str) -> str:
|
|
978
|
+
"""Create a project slug."""
|
|
979
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
980
|
+
return slug or "agent"
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _normalize_builtin_tools(tools: list[str]) -> list[str]:
|
|
984
|
+
"""Return stable, de-duplicated builtin tool ids."""
|
|
985
|
+
normalized: list[str] = []
|
|
986
|
+
for tool in tools:
|
|
987
|
+
value = re.sub(r"[^a-z0-9_-]+", "_", tool.lower()).strip("_-")
|
|
988
|
+
if value and value not in normalized:
|
|
989
|
+
normalized.append(value)
|
|
990
|
+
return normalized
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def _string_list(value: object, *, field_name: str, errors: list[str]) -> list[str]:
|
|
994
|
+
"""Return a string list or append a validation error."""
|
|
995
|
+
if value is None:
|
|
996
|
+
return []
|
|
997
|
+
if not isinstance(value, list):
|
|
998
|
+
errors.append(f"{field_name} must be a list.")
|
|
999
|
+
return []
|
|
1000
|
+
result: list[str] = []
|
|
1001
|
+
for index, item in enumerate(value):
|
|
1002
|
+
if not isinstance(item, str) or not item.strip():
|
|
1003
|
+
errors.append(f"{field_name}[{index}] must be a non-empty string.")
|
|
1004
|
+
continue
|
|
1005
|
+
result.append(item)
|
|
1006
|
+
return result
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _valid_model_name(value: str) -> bool:
|
|
1010
|
+
"""Validate model identifiers used by the current public manifest format."""
|
|
1011
|
+
return bool(value) and value.startswith(VALID_MODEL_PREFIXES) and " " not in value
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _contains_secret_placeholder(value: str) -> bool:
|
|
1015
|
+
"""Return whether a value contains an accepted credential placeholder."""
|
|
1016
|
+
return bool(SECRET_PLACEHOLDER_RE.search(value))
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _is_sensitive_key(value: str) -> bool:
|
|
1020
|
+
"""Return whether an env/header key is likely secret-bearing."""
|
|
1021
|
+
normalized = re.sub(r"[^a-z0-9]+", "_", value.lower())
|
|
1022
|
+
return any(marker in normalized for marker in SENSITIVE_KEY_MARKERS)
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _positive_int_value(value: object) -> bool:
|
|
1026
|
+
"""Return whether a value is a positive integer."""
|
|
1027
|
+
return not isinstance(value, bool) and isinstance(value, int) and value >= 1
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _positive_int_or_default(
|
|
1031
|
+
value: object,
|
|
1032
|
+
*,
|
|
1033
|
+
default: int,
|
|
1034
|
+
field_name: str,
|
|
1035
|
+
errors: list[str],
|
|
1036
|
+
) -> int:
|
|
1037
|
+
"""Return a positive integer or record an error and return a default."""
|
|
1038
|
+
if value is None:
|
|
1039
|
+
return default
|
|
1040
|
+
if not isinstance(value, bool) and isinstance(value, int) and value >= 1:
|
|
1041
|
+
return value
|
|
1042
|
+
errors.append(f"{field_name} must be a positive integer.")
|
|
1043
|
+
return default
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def _display_name(value: str) -> str:
|
|
1047
|
+
"""Create a display name."""
|
|
1048
|
+
return value.replace("-", " ").replace("_", " ").title()
|